说个多年老前端都不知道的标签正确玩法——q标签
最近这两天准备鼓捣一下markdown文本编辑器,现在写公众号一般用的都是 网页 的编辑器。
说实话,很方便,但是痛点也很明显。
研究过程中发现一个以前从未在意过的标签: <q> 标签。
官网解释
<p>孟子: <q>生于忧患,死于安乐。</q></p>
说实话原生效果比较难看。
仅仅是对文本增加了双引号,并且这个双引号效果在各个浏览器中好像还存在细微的区别。
另外就是效果对于常规文本而言没有什么问题,但是对于大段文字、需要重点突出的文字而言其实比较普通,混杂在海量的文字中间很难分辨出来效果。
所以可以通过css全局修改q标签的样式,使其更符合个性化样式的需求。
q {
quotes: "「" "」";
color: #3594F7;
font-weight: bold;
}
最大限度模仿了markdown上面的样式效果。
其实上述样式中的双引号还可以被替换成图片、表情、文字等等,并且也可以通过伪元素对双引号进行操作。
q {
quotes: "🙂" "🙃";
color: #3594F7;
font-weight: bold;
}
q::before {
display: inline-block;
}
q::after {
display: inline-block;
}
q:hover::before,
q:hover::after {
animation: rotate 0.5s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
注意:伪元素上必须添加 display: inline-block;
,否则动画不生效。
原因是伪元素默认为 inline
,部分css样式对 inline
是不生效的。
来源:juejin.cn/post/7516745491104481315
前端权限系统怎么做才不会写吐?我们项目踩过的 3 套失败方案总结
上线前两个月,我们的权限系统崩了三次。
不是接口没权限,而是:
- 页面展示和真实权限不一致;
- 权限判断写得四分五裂;
- 权限数据和按钮逻辑耦合得死死的,测试一改就炸。
于是,我们老老实实把整个权限体系拆了重构,从接口到路由、到组件、到 v-permission 指令,走了一遍完整的流程。
结果:代码可维护,调试容易,后端调整也能快速兜底。
这篇文章不讲理论,只还原我们项目真踩过的 3 套失败方案和最终落地方案。
❌ 第一套:按钮级权限直接写死在模板里
当时我们的写法是这样的:
<!-- 用户管理页 -->
<el-button v-if="authList.includes('user:add')">添加用户</el-button>
接口返回的是一个权限数组:
["user:add", "user:delete", "user:list"]
然后整个项目几十个地方都这么判断。
结果:
- 不能重用,每个组件都判断一次;
- 权限粒度变更就全崩,比如从
user:add
改成user:add_user
; - 后端权限更新后,前端要全局搜索权限 key 改代码;
典型的“写起来爽,维护时哭”方案。
❌ 第二套:用 router.meta.permission 统一控制,结果太抽象
重构后我们尝试统一控制页面级权限:
// router.ts
{
path: '/user',
component: User,
meta: {
permission: 'user:list'
}
}
再通过导航守卫:
router.beforeEach((to, from, next) => {
const p = to.meta.permission
if (p && !authList.includes(p)) {
return next('/403')
}
next()
})
这个方案页面级权限是解决了,但组件级 / 按钮级 / 表单字段级全都失效了。
而且你会发现,大量页面是“同路由但不同内容区域权限不同”,导致这种 meta.permission
方案显得太粗暴。
❌ 第三套:封装权限组件,结果被吐槽“反人类”
当时我们团队有人设计了一个组件:
<Permission code="user:add">
<el-button>添加用户</el-button>
</Permission>
这个组件内部逻辑是:
const slots = useSlots()
if (!authList.includes(props.code)) return null
return slots.default()
结果:
- 逻辑上看似没问题,但使用非常反直觉;
- 特别是嵌套多个组件时,调试麻烦,断点打不进真实组件;
- TypeScript 报类型错误,编辑器无法识别 slot 类型;
- 更麻烦的是,权限失效的时候,组件不会渲染,开发环境都看不到是为什么!
最终方案:hook + 指令 + 路由统一层级设计
我们最后把权限体系重构为 3 层:
🔹1. 接口统一管理权限 key → 后端返回精简列表(扁平权限)
export type AuthCode =
| 'user:add'
| 'user:delete'
| 'user:edit'
| 'order:export'
| 'dashboard:view'
服务端返回用户权限集,保存在 authStore
(Pinia / Vuex / Context)中。
🔹2. 统一 Hook 调用:usePermission(code)
import { useAuthStore } from '@/store/auth'
export function usePermission(code: string): boolean {
const store = useAuthStore()
return store.permissionList.includes(code)
}
用法:
<el-button v-if="usePermission('user:add')">添加用户</el-button>
这才是真正组件内部逻辑干净、容易复用、TS 支持的方案。
🔹3. 封装一个 v-permission 指令(可选)
app.directive('permission', {
mounted(el, binding) {
const authList = getUserPermissions() // 从全局 store 获取
if (!authList.includes(binding.value)) {
el.remove()
}
}
})
模板中使用:
<el-button v-permission="'order:export'">导出订单</el-button>
适合动态组件、render 生成的按钮,不适合复杂嵌套逻辑,但实际项目中效果拔群。
🧪 页面级权限怎么做?
不再用 router.meta
,而是把每个路由页封装为权限包裹组件:
<template>
<PermissionView code="dashboard:view">
<Dashboard />
</PermissionView>
</template>
权限组件内部处理:
- 没权限 → 自动跳转 403
- 有权限 → 渲染内容
这样即使权限接口变了,组件逻辑也统一保留,避免页面空白或者闪跳。
权限这事,不是实现难,而是维护难。
最核心的不是你怎么控制显示,而是权限 key 的一致性、复用性、分层能力。
最终我们稳定版本满足了:
- 页面、按钮、字段统一接入权限
- 新增权限点只需要改枚举,不需要大改
- 新人接手也能一眼看懂逻辑,能调试
📌 你可以继续看我的系列文章
来源:juejin.cn/post/7517915625136586787
同事用了个@vue:mounted,我去官网找了半天没找到
前言
大家好,我是奈德丽。
上周在做代码review的时候,看到同事小李写了这样一行代码:
<component :is="currentComponent" @vue:mounted="handleMounted" />
我第一反应是:"这什么语法?似曾相识的样子,有点像在vue2中用过的@hook:mounted
, 但我们项目是vue3,然后去Vue3官方文档搜索@vue:mounted
,结果什么都没找到,一开始我以为是他研究了源码,结果他说是百度到的,那我们一起来来研究研究这个东西吧。
从一个动态组件说起
小李的需求其实很简单:在子组件加载或更新或销毁后,需要获取组件的某些信息。这家伙是不是还看源码了,有这种骚操作,他的代码是这样的:
<template>
<div class="demo-container">
<h2>动态组件加载监控</h2>
<div class="status">当前组件状态:{{ componentStatus }}</div>
<div class="controls">
<button @click="loadComponent('ComponentA')">加载组件A</button>
<button @click="loadComponent('ComponentB')">加载组件B</button>
<button @click="unloadComponent">卸载组件</button>
</div>
<!-- 小李写的代码 -->
<component
:is="currentComponent"
v-if="currentComponent"
@vue:mounted="handleMounted"
@vue:updated="handleUpdated"
@vue:beforeUnmount="handleBeforeUnmount"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const currentComponent = ref(null)
const componentStatus = ref('无组件')
const handleMounted = () => {
componentStatus.value = '✅ 组件已挂载'
console.log('组件挂载完成')
}
const handleUpdated = () => {
componentStatus.value = '🔄 组件已更新'
console.log('组件更新完成')
}
const handleBeforeUnmount = () => {
componentStatus.value = '❌ 组件即将卸载'
console.log('组件即将卸载')
}
const loadComponent = (name) => {
currentComponent.value = name
}
const unloadComponent = () => {
currentComponent.value = null
componentStatus.value = '无组件'
}
</script>
我仔细分析了一下,在这个动态组件的场景下,@vue:mounted
确实有它的优势。最大的好处是只需要在父组件一个地方处理,不用去修改每个可能被动态加载的子组件。想象一下,如果有十几个不同的组件都可能被动态加载,你得在每个组件里都加上emit事件,维护起来确实麻烦。
而用@vue:mounted
的话,所有的生命周期监听逻辑都集中在父组件这一个地方,代码看起来更集中,也更好管理。
但是,我心里还是有疑虑:这个语法为什么在官方文档里找不到?
深入探索:未文档化的功能
经过一番搜索,我在Vue的GitHub讨论区找到了答案。原来这个功能确实存在,但Vue核心团队明确表示:
"这个功能不是为用户应用程序设计的,这就是为什么我们决定不文档化它。"
换句话说:
- ✅ 这个功能确实存在且能用
- ❌ 但官方不保证稳定性
- ⚠️ 可能在未来版本中被移除
- 🚫 不推荐在生产环境使用
我们来看一下vue迁移文档中关于Vnode的部分,关键点我用下划线标红了。有趣的是这个@vue:[生命周期]语法不仅可以用在组件上,也可以用在所有虚拟节点中。
虽然在Vue 3迁移指南中有提到从@hook:
(Vue 2)改为@vue:
(Vue 3)的变化,但这更多是为了兼容性考虑,而不是鼓励使用。
为什么小李的代码"看起来"没问题?
回到小李的动态组件场景,@vue:mounted
确实解决了问题:
- 集中管理 - 所有生命周期监听逻辑都在父组件一个地方
- 动态性强 - 不需要知道具体加载哪个组件
- 代码简洁 - 不需要修改每个子组件
- 即用即走 - 临时监听,用完就完
但问题在于,这是一个不稳定的API,随时可能被移除。
我给出的review意见
考虑到安全性和稳定性,还是以下方案靠谱
方案一:子组件主动汇报(推荐)
虽然需要修改子组件,但这是最可靠的方案:
<!-- ComponentA.vue -->
<template>
<div class="component-a">
<h3>我是组件A</h3>
<button @click="counter++">点击次数: {{ counter }}</button>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const emit = defineEmits(['lifecycle'])
const counter = ref(0)
onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentA' })
})
onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentA' })
})
onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentA' })
})
</script>
<!-- ComponentB.vue -->
<template>
<div class="component-b">
<h3>我是组件B</h3>
<input v-model="text" placeholder="输入文字">
<p>{{ text }}</p>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const emit = defineEmits(['lifecycle'])
const text = ref('')
onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentB' })
})
onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentB' })
})
onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentB' })
})
</script>
父组件使用:
<component
:is="currentComponent"
v-if="currentComponent"
@lifecycle="handleLifecycle"
/>
<script setup>
const handleLifecycle = ({ type, componentName }) => {
const statusMap = {
mounted: '✅ 已挂载',
updated: '🔄 已更新',
beforeUnmount: '❌ 即将卸载'
}
componentStatus.value = `${componentName} ${statusMap[type]}`
console.log(`${componentName} ${type}`)
}
</script>
优点:稳定可靠,官方推荐
缺点:需要修改每个子组件,有一定的重复代码
方案二:通过ref访问(适合特定场景)
如果你确实需要访问组件实例:
<component
:is="currentComponent"
v-if="currentComponent"
ref="dynamicComponentRef"
/>
<script setup>
import { ref, watch, nextTick } from 'vue'
const dynamicComponentRef = ref(null)
// 监听组件变化
watch(currentComponent, async (newComponent) => {
if (newComponent) {
await nextTick()
console.log('组件实例:', dynamicComponentRef.value)
componentStatus.value = '✅ 组件已挂载'
// 可以访问组件的方法和数据
if (dynamicComponentRef.value?.someMethod) {
dynamicComponentRef.value.someMethod()
}
}
}, { immediate: true })
</script>
优点:可以直接访问组件实例和方法
缺点:只能监听到挂载,无法监听更新和卸载
方案三:provide/inject(深层通信)
如果是复杂的嵌套场景,组件层级深的时候我们可以使用这个:
<!-- 父组件 -->
<script setup>
import { provide, ref } from 'vue'
const componentStatus = ref('无组件')
const lifecycleHandler = {
onMounted: (name) => {
componentStatus.value = `✅ ${name} 已挂载`
console.log(`${name} 已挂载`)
},
onUpdated: (name) => {
componentStatus.value = `🔄 ${name} 已更新`
console.log(`${name} 已更新`)
},
onBeforeUnmount: (name) => {
componentStatus.value = `❌ ${name} 即将卸载`
console.log(`${name} 即将卸载`)
}
}
provide('lifecycleHandler', lifecycleHandler)
</script>
<template>
<div>
<div class="status">{{ componentStatus }}</div>
<component :is="currentComponent" v-if="currentComponent" />
</div>
</template>
<!-- 子组件 -->
<script setup>
import { inject, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const lifecycleHandler = inject('lifecycleHandler', {})
const componentName = 'ComponentA' // 每个组件设置自己的名称
onMounted(() => {
lifecycleHandler.onMounted?.(componentName)
})
onUpdated(() => {
lifecycleHandler.onUpdated?.(componentName)
})
onBeforeUnmount(() => {
lifecycleHandler.onBeforeUnmount?.(componentName)
})
</script>
优点:适合深层嵌套,可以跨多层传递
各种方案的对比
方案 | 实现难度 | 可靠性 | 维护性 | 集中管理 | 适用场景 |
---|---|---|---|---|---|
emit事件 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ | 🏆 大部分场景的首选 |
ref访问 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ✅ | 需要调用组件方法时 |
provide/inject | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 深层嵌套组件通信 |
@vue:mounted | ⭐ | ⭐⭐ | ⭐ | ✅ | ⚠️ 自己项目可以玩玩,不推荐生产使用 |
总结
通过这次code review,我们学到了:
- 技术选型要考虑长远 - 不是所有能用的功能都应该用,稳定性比便利性更重要
- 特定场景的权衡 - 在动态组件场景下,
@vue:[生命周期]
确实有集中管理的优势,但要权衡风险 - 迁移策略很重要 - 不能一刀切,要有合理的过渡方案
- 代码review的价值 - 不仅仅是找bug,更是知识分享和技术决策的过程
- 文档化的重要性 - 未文档化的API往往意味着不稳定,使用时要谨慎
虽然@vue:[生命周期]
在动态组件场景下确实好用,但从工程化角度考虑,还是建议逐步迁移到官方推荐的方案。毕竟,今天的便利可能是明天的技术债务。
当然,如果你正在维护老项目,且迁移成本较高,也可以考虑先保留现有代码,但一定要有明确的迁移计划和风险控制措施。
恩恩……懦夫的味道
来源:juejin.cn/post/7514275553726644235
😧纳尼?前端也能做这么复杂的事情了?
前言
我偶然间发现一个宝藏网站,aicut.online 是一款基于本地AI实现的背景移除工具。
我研究了一下,发现他是使用了u2net
模型 + onnxruntime-web
实现的本地模型推理能力,下面简单介绍一下这些概念。
github:github.com/yuedud/aicu…
体验网址:aicut.online
概念
WebAssembly
- 基本概念: WebAssembly 是一种低级的二进制指令格式,设计目标是成为一种高效、可移植、安全的编译目标,使其能在现代 Web 浏览器中运行。你可以把它想象成一种为 Web 设计的“通用机器语言”。
- 核心特点:
- 高性能: 它不是解释执行的(像传统 JavaScript),而是被设计成可以以接近原生代码的速度运行。它提供线性内存模型和低级操作,便于编译器优化。
- 可移植性: Wasm 模块是平台无关的,可以在支持 Wasm 的任何浏览器(或运行时环境)中运行,无需修改。
- 安全性: 它在内存安全的沙箱环境中执行,无法直接访问主机操作系统或 DOM。只能通过明确定义的 API 与宿主环境(如浏览器)交互。
- 多语言支持: 开发者可以使用 C、C++、Rust、Go 等多种语言编写代码,然后编译成 Wasm 模块,在浏览器中运行。这使得重用现有的高性能库或编写对性能要求极高的新功能成为可能。
- 目标: 解决 JavaScript 在处理计算密集型任务(如游戏物理引擎、视频编辑、3D渲染、科学计算、加密解密、机器学习模型推理等)时性能不足的问题,同时保持 Web 的安全性和可移植性。
- 简单比喻: 就像为浏览器引入了一个新的、更接近硬件的“CPU 指令集”,让浏览器能直接运行编译好的高性能代码。
Onnxruntime-Web
- 基本概念: onnxruntime-web 是 ONNX Runtime 的一个专门构建的版本,目的是让开发者能够直接在 Web 浏览器中运行 ONNX 格式的机器学习模型。
- 核心特点:
- ONNX 支持: 它理解并执行符合 ONNX 标准的模型文件。ONNX 是一个开放的模型格式,允许模型在各种框架之间转换和互操作。
- 浏览器内推理: 最大的价值在于它允许 ML 模型的推理计算完全在用户的浏览器中发生,无需依赖远程服务器。这带来了低延迟、隐私保护(数据无需离开用户设备)和离线能力。
- 多种后端执行引擎: 为了适应不同的浏览器环境、设备性能和模型需求,它提供了多种执行引擎后端:
- WebAssembly (Wasm): 提供接近原生的性能,是主要的跨浏览器高性能后端。支持单线程和多线程(需浏览器支持)。
- WebGL: 利用 GPU 进行加速,尤其适合某些计算模式与图形处理相似的模型(如卷积神经网络)。性能潜力高,但兼容性和精度可能不如 Wasm。
- WebNN (预览/实验性): 旨在利用操作系统提供的原生 ML 硬件加速(如 NPU)。性能潜力最高,但目前浏览器支持有限。
- JavaScript (CPU): 兼容性最好但速度最慢的后备方案。
- 优化: 包含针对 Web 环境(特别是 Wasm 和 WebGL)的特定优化,以提升模型在浏览器中的运行效率。
- 目标: 降低在 Web 应用中集成和部署机器学习模型的门槛,提供高性能、跨平台的浏览器内推理能力。
- 简单比喻: 它是一个专门为浏览器定制的“机器学习模型运行引擎”,支持多种“驱动方式”(Wasm, WebGL, WebNN),让各种 ONNX 格式的模型能在网页里“活”起来并高效工作。
u2net
- 基本概念: u2net 是一种深度学习神经网络架构,特别设计用于显著目标检测任务。它的核心任务是从图像或视频中精确地分割出最吸引人注意的前景目标。
- 核心特点:
- 嵌套 U 型结构: 这是其名称的由来(U^2-Net)。它包含一个主 U 型编码器-解码器网络,并且在每个阶段内部又嵌套了更小的 U 型块(ReSidual U-blocks, RSU)。这种设计能更有效地捕捉不同尺度的上下文信息,同时保持高分辨率的细节。
- 多尺度特征融合: 通过嵌套的 RSU 块和跳跃连接,模型能融合来自不同深度和尺度的特征,这对精确描绘目标边界至关重要。
- 高效性: 相比一些非常深的网络(如 ResNet),u2net 结构相对轻量,但性能优异。
- 应用广泛: 主要用于高质量的图像/视频前景背景分割(抠图)。典型的应用包括:
- 移除或替换图片/视频背景
- 创建透明 PNG 图像
- 人像分割
- 视频会议虚拟背景
- 图像编辑工具
- 目标: 提供一种高效且准确的架构,解决图像中前景目标的精确分割问题。
- 简单比喻: u2net 是一个专门训练出来的“智能剪刀手”,它能自动识别图片里最重要的主体(比如人、动物、物体),并用极高的精度把它从背景中“剪”出来。
技术架构
架构图
+-------------------------------------------------------+
| **用户层 (Web Application)** |
+-------------------------------------------------------+
| - 用户界面 (HTML, CSS) |
| - 业务逻辑 (JavaScript/TypeScript) |
| * 捕获用户输入 (e.g., 上传图片/视频流) |
| * 调用 `onnxruntime-web` API 执行推理 |
| * 处理输出 (e.g., 显示抠图结果,合成新背景) |
+-------------------------------------------------------+
↓ (JavaScript API 调用)
+-------------------------------------------------------+
| **模型服务层 (ONNX Runtime Web)** |
+-------------------------------------------------------+
| - **onnxruntime-web** 库 (JavaScript) |
| * 加载并解析 **u2net.onnx** 模型文件 |
| * 管理输入/输出张量 (Tensor) 的内存 |
| * 调度计算任务到下层执行引擎 |
| * 提供统一的 JavaScript API 给上层应用 |
+-------------------------------------------------------+
↓ (选择最佳后端执行)
+-------------------------------------------------------+
| **执行引擎层 (Runtime Backends)** |
+-------------------------------------------------------+
| +---------------------+ +---------------------+ |
| | **WebAssembly (Wasm)** | **WebGL** | ... |
| +---------------------+ +---------------------+ |
| | * **核心加速引擎** | * 利用GPU加速 | |
| | * 接近原生CPU速度 | * 适合特定计算模式 | |
| | * 安全沙箱环境 | * 兼容性/精度限制 | |
| | * 多线程支持 (可选) | | |
| +---------------------+ +---------------------+ |
| **首选后端** **备选/补充后端** |
+-------------------------------------------------------+
↓ (执行编译后的低级代码)
+-------------------------------------------------------+
| **模型层 (U2Net 神经网络)** |
+-------------------------------------------------------+
| - **u2net.onnx** 模型文件 |
| * 包含训练好的 u2net 网络架构 (嵌套U型结构) |
| * 包含网络权重参数 |
| * 格式:开放神经网络交换格式 (ONNX) |
| * 任务:显著目标检测 / 图像抠图 |
+-------------------------------------------------------+
↓ (模型文件来源)
+-------------------------------------------------------+
| **资源层 (Browser Environment)** |
+-------------------------------------------------------+
| - 模型文件存储: HTTP Server / IndexedDB / Cache API |
| - 浏览器提供: WebAssembly 引擎, WebGL API, WebNN API |
| - 计算资源: CPU (Wasm), GPU (WebGL), NPU (WebNN) |
+-------------------------------------------------------+
详细解释
- 用户层 (Web Application):
- 这是用户直接交互的网页界面。
- 使用 JavaScript/TypeScript 编写应用逻辑。
- 核心操作:获取用户输入(如图片或视频帧),调用
onnxruntime-web
提供的 API 来运行 u2net 模型进行抠图推理,接收模型输出的结果(通常是掩码图或透明度通道),最后将结果渲染给用户(如显示抠好的图或与背景合成)。
- 模型服务层 (ONNX Runtime Web):
- 核心枢纽。这是集成到 Web 应用中的 JavaScript 库。
- 负责加载存储在资源层中的 u2net.onnx 模型文件。
- 管理模型运行所需的内存(准备输入 Tensor,接收输出 Tensor)。
- 提供简洁的 JS API(如
InferenceSession.create()
,session.run()
)供上层应用调用。 - 最关键的作用:根据浏览器支持情况和模型需求,智能选择并调度计算任务到下层的最佳执行引擎(首选通常是 WebAssembly)。
- 执行引擎层 (Runtime Backends):
onnxruntime-web
实际执行模型计算的地方。- WebAssembly (Wasm) 后端是核心加速引擎:
- u2net 模型的计算密集型操作(卷积、矩阵乘等)被编译成高效的 Wasm 字节码。
- Wasm 引擎在浏览器的安全沙箱中以接近原生代码的速度执行这些字节码。
- 这是实现高性能浏览器内推理的关键,使得复杂的 u2net 模型能在用户设备上流畅运行。
- WebGL 后端 (备选) :
- 利用 GPU 进行加速,特别适合 u2net 中大量使用的卷积操作。
- 性能潜力高,但可能受浏览器兼容性、WebGL 精度限制和特定模型适配的影响。
- (可选) WebNN 后端 (未来方向) :直接调用操作系统提供的底层 AI 硬件加速(如 NPU),潜力最大,但目前支持有限。
- 模型层 (U2Net 神经网络):
- 包含训练好的 u2net 模型,以 ONNX 格式 (.onnx 文件) 存储。
- ONNX 是一个开放的、框架无关的模型表示格式,使得 u2net 模型可以被 onnxruntime-web 加载和运行。
- 这个文件包含了 u2net 独特的嵌套 U 型结构 (U^2-Net) 的定义以及训练得到的所有权重参数。
- 它定义了具体的抠图任务如何执行。
- 资源层 (Browser Environment):
- 提供模型文件
u2net.onnx
的来源(通过 HTTP 下载、存储在 IndexedDB 或利用 Cache API)。 - 提供运行时环境:浏览器内置的 WebAssembly 引擎负责执行 Wasm 字节码,WebGL API 用于 GPU 加速,WebNN API (如果可用) 用于底层硬件加速。
- 提供硬件计算资源:用户的 CPU (用于运行 Wasm)、GPU (用于 WebGL)、潜在的专用 AI 处理器 NPU/APU (用于 WebNN)。
- 提供模型文件
源代码解析
Github:github.com/yuedud/aicu…
目录解析
public
public是存放静态资源的地方,存储了onnx模型和一些静态的资源图片
src
src是核心代码存放的地方,下面我们只来介绍一下关于抠图部分的代码,核心代码在src/components/ImageSegmentation.js
可以看到在进入网站之后,第一时间就开始加载模型,同时使用了indexedDB进行了模型缓存,二次使用的时候直接用indexedDB里获取模型,由于模型较大,所以加载时间会比较长。
// 加载模型
useEffect(() => {
const loadModel = async () => {
try {
setError(null);
const db = await openDB();
let modelData = await getModelFromDB(db);
if (modelData) {
console.log('从IndexedDB加载模型.');
} else {
console.log('IndexedDB中未找到模型,从网络下载...');
const response = await fetch('./u2net.onnx');
if (!response.ok) {
throw new Error(`网络请求模型失败: ${response.status} ${response.statusText}`);
}
modelData = await response.arrayBuffer();
console.log('模型下载完成,存入IndexedDB...');
await storeModelInDB(db, modelData);
console.log('模型已存入IndexedDB.');
}
const newSession = await ort.InferenceSession.create(modelData, {
executionProviders: ['wasm'], // 'webgl' 或 'wasm'
graphOptimizationLevel: 'all',
});
setSession(newSession);
console.log('ONNX模型加载并初始化成功');
} catch (e) {
console.error('ONNX模型加载或初始化失败:', e);
setError(`模型处理失败: ${e.message}`);
}
};
loadModel();
}, []);
然后可以看到在上传完图片之后进行了图片的预处理,主要是将图片转换成了模型的入参Tensor
const preprocess = async (imgElement) => {
const canvas = document.createElement('canvas');
const modelWidth = 320;
const modelHeight = 320;
canvas.width = modelWidth;
canvas.height = modelHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0, modelWidth, modelHeight);
const imageData = ctx.getImageData(0, 0, modelWidth, modelHeight);
const data = imageData.data;
const float32Data = new Float32Array(1 * 3 * modelHeight * modelWidth);
const mean = [0.485, 0.456, 0.406];
const std = [0.229, 0.224, 0.225];
for (let i = 0; i < modelHeight * modelWidth; i++) {
float32Data[i] = (data[i * 4] / 255 - mean[0]) / std[0]; // R
float32Data[i + modelHeight * modelWidth] = (data[i * 4 + 1] / 255 - mean[1]) / std[1]; // G
float32Data[i + 2 * modelHeight * modelWidth] = (data[i * 4 + 2] / 255 - mean[2]) / std[2]; // B
}
return new ort.Tensor('float32', float32Data, [1, 3, modelHeight, modelWidth]);
};
然后就是将模型的入参放到模型中去推理
const runSegmentation = async () => {
if (!image || !session) {
setError('请先上传图片并等待模型加载完成。');
return;
}
setError(null);
setOutputImage(null);
try {
const imgElement = imageRef.current;
if (!imgElement) {
throw new Error('图片元素未找到。');
}
// 确保图片完全加载
if (!imgElement.complete) {
await new Promise(resolve => { imgElement.onload = resolve; });
}
const inputTensor = await preprocess(imgElement);
const feeds = { 'input.1': inputTensor }; // 确保输入名称与模型一致
const results = await session.run(feeds);
const outputTensor = results[session.outputNames[0]];
const outputDataURL = postprocess(outputTensor, imgElement);
setOutputImage(outputDataURL);
} catch (e) {
console.error('抠图失败:', e);
setError(`抠图处理失败: ${e.message}`);
}
};
当模型推理完之后,进行模型推理结果的后处理,主要是将alpha通道和原图片进行合成
// 后处理:将模型输出转换为透明背景图像
const postprocess = (outputTensor, originalImgElement) => {
const outputData = outputTensor.data;
const [height, width] = outputTensor.dims.slice(-2); // 通常是 [1, 1, H, W]
const canvas = document.createElement('canvas');
canvas.width = originalImgElement.naturalWidth; // 使用原始图片尺寸
canvas.height = originalImgElement.naturalHeight;
const ctx = canvas.getContext('2d');
// 1. 绘制原始图片
ctx.drawImage(originalImgElement, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixelData = imageData.data;
// 2. 创建一个临时的canvas来处理和缩放mask
const maskCanvas = document.createElement('canvas');
maskCanvas.width = width; // U2Net输出mask的原始宽度
maskCanvas.height = height; // U2Net输出mask的原始高度
const maskCtx = maskCanvas.getContext('2d');
const maskImageData = maskCtx.createImageData(width, height);
// 归一化mask值 (通常U2Net输出在0-1之间,但最好检查一下)
let minVal = Infinity;
let maxVal = -Infinity;
for (let i = 0; i < outputData.length; i++) {
minVal = Math.min(minVal, outputData[i]);
maxVal = Math.max(maxVal, outputData[i]);
}
for (let i = 0; i < height * width; i++) {
let value = (outputData[i] - minVal) / (maxVal - minVal); // 归一化到 0-1
value = Math.max(0, Math.min(1, value)); // 确保在0-1范围内
const alpha = value * 255;
maskImageData.data[i * 4] = 0; // R
maskImageData.data[i * 4 + 1] = 0; // G
maskImageData.data[i * 4 + 2] = 0; // B
maskImageData.data[i * 4 + 3] = alpha; // Alpha
}
maskCtx.putImageData(maskImageData, 0, 0);
// 3. 将缩放后的mask应用到原始图像的alpha通道
// 创建一个新的canvas用于绘制最终结果,并将mask缩放到原始图像尺寸
const finalMaskCanvas = document.createElement('canvas');
finalMaskCanvas.width = originalImgElement.naturalWidth;
finalMaskCanvas.height = originalImgElement.naturalHeight;
const finalMaskCtx = finalMaskCanvas.getContext('2d');
finalMaskCtx.drawImage(maskCanvas, 0, 0, finalMaskCanvas.width, finalMaskCanvas.height);
const finalMaskData = finalMaskCtx.getImageData(0, 0, finalMaskCanvas.width, finalMaskCanvas.height);
for (let i = 0; i < pixelData.length / 4; i++) {
pixelData[i * 4 + 3] = finalMaskData.data[i * 4 + 3]; // 将mask的alpha通道应用到原始图片
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
};
至此将合成的图片渲染到屏幕上就可以了。
如何启动
首先我们要对仓库进行克隆
git clone https://github.com/yuedud/aicut.git
然后安装依赖
npm install
然后直接启动项目
npm start
启动之后你就可以在本地尝试背景移除工具。
来源:juejin.cn/post/7512058418623971343
UI设计不求人,对话 Figma MCP 直出UI设计稿!
引言
🤡 年初立过的Flag中包含一条:开发开源个人效率APP——惜命,这都半年过去了,搞得怎么样啦~
🤣 em... 有在做的啦~
就是进度有点慢,搞了这么久,还TM在 搞天气的模块,em... 光UI都改几次了,第二版UI:
第三版UI:
归根结底还是一个字 "乱+完美主义",对自己想要的目标非常模糊,以往都是 产品经理捋清交互出原型,设计师出设计稿,我照葫芦画瓢写界面就好了。🐶 而现在这两个都要我自己来做,产品功能还好,我自己梳理清楚逻辑就行,但 UI设计 这块,我是真的一窍不通,完全无从下手。🤡 上面两个界面都是写提示词让 Cursor 直接写的页面,主打一个 随缘,但也带来了问题:页面风格的不一致,上一个页面是 Material 风格,下一个页面秒变 iOS 风格,🙃 让人有一种撕裂感。
🤔 一种解法是写一堆长篇大论的 rule 来严格限制 Cursor 生成的画面风格,另一种就是自己整 UI设计稿 (原型),我选择了后者,学PS是不可能的🐶,周期太长了,搜了圈"简单UI设计工具",很多人安利用 Figma,直接B站搜 "Figma速成",选了这个快速看完:
《Figma新手教程2025|30分钟高效掌握Figma基础操作与UI设计流程》
😄 照着Up主的视频走了一遍案例,工具操作确实不复杂,然后觉得自己强得可怕💪,新建惜命项目,然后对着空白页面,我又陷入了呆滞,TM该怎么开始 ???根本不知道要弄成什么样的页面...
🤡 归根结底:工具是"术",设计理论是"道" ,关于道我一点 经验积累 都没有,这需要大量的看和模仿练习。自己画不出来,但是画得好不好看,我是能评判的,突然有一个想法:🤔 能不能让 AI 出 线框图,我再自己调整和细化?😳 Figma 是有AI功能的,但现在只有 付费用户 能用,白嫖教育版 没法耍咯:
😏 没法用官方的AI功能,但有 MCP Server 啊!官方有一个 Dev Mode MCP,试了下不太好用🤷♀️:
《Introducing our Dev Mode MCP server: Bringing Figma int0 your workflow》
《Guide to the Dev Mode MCP Server》
🐶 也可能不太符合我们的场景,直接在它的 插件商店 搜了下,发现这个:Cursor Talk To Figma MCP Plugin
👍 这插件还是开源的:
sonnylazuardi/cursor-talk-to-figma-mcp
插件效果视频:
😋 体验了一下,确实是我们想要的 嘴遁出Figma设计稿的MCP,接着详细介绍下怎么用~
安装
① Clone 项目到本地
git clone https://github.com/sonnylazuardi/cursor-talk-to-figma-mcp.git
顺手让 Cursor 生成一份 详细的项目结构说明文档:
😄 不难看出这个MCP主要由三部分组成:MCP服务、WebSocket通信、Figma插件,对具体实现感兴趣的童鞋,可以自行看下生成的文档:
② 安装 Bun
安装完,重启下 PowerShell,键入 bun -v 查看版本号,确定是否安装成功:
③ 初始化项目
接着 cd 到项目的根目录,执行 bun setup 进行 初始化,🐶 理论上是这样,但 Windows 运行会直接报错,原因是系统 不支持直接运行.sh脚本文件:
🤡 解法就是:手动执行 setup.sh 脚本里的命令:
😶 其实就是创建下 .cursor/mcp.json文件 和 执行 bun install (😄 搞不定就问Cursor~),安装完后:
④ 启动Websocket
键入 bun socket 启动 Websocket:
⑤ Cursor 配置 MCP
Chat Settings → MCP Tools →TalkToFigma (一般默认有的,没有自己就配下,很简单) → 启用
🤡 我这里启用完是红的 (正常是绿色的),说明有问题:
试了下文档中提到的 windows wsl 要去掉这行的注释:
然后 Ctrl+C 停掉 WebSocket 服务,然后再执行 bun socket,依旧爆红... 🐶 折腾了一会儿发现,是 Cursor 终端没有更新 (装了Bun要重启),重启下 Cursor 就好了:
⑥ 安装Figma桌面端 + 配置Figma 插件
点击用户头像,下拉找到 Get desktop app 进行下载安装:
打开桌面端,进入 要生成设计稿的Page,点击 Actions:
底下会有弹窗,依次点击:Plugins & widgets → Import from manifest..
然后按照下图中的路径选中 manifest.json 文件:
接着点击这个插件:
会弹窗,显示正在连接上面启动的 Websocket 服务 (如果失败的话,重启试试,在 Cursor 的终端直接执行 bun socket!)
这个 Channel ID 等下 Cursor 也要用到,终端也会输出:
Cursor 切 Agent 模式,输入提示词进行链接,示例:
- 使用channel: channel ID 连接服务和Figma进行对话
- Talk to Figma, channel [您的Channel ID]
连接后会有输出信息:
接着让它开始整设计稿,弄个 简单的登录页 看看效果,Cursor 疯狂输出:
另一边 Figma桌面端 也是热火朝天的堆砌UI:
最终输出结果:
🐶 左上角这个 表单区域 有点迷,还有登录按钮上那个 紫色半透明圆形,Shift + 鼠标 选中 这三组件:
Cursor 的回答:
完全不懂这什么设计...
接着让它删掉这三,移动下组件,添加一个同意隐私协议的组件:
最终效果:
🐂🍺,Cursor 通过这个 MCP,不止能读,还能操作设计稿 👍。另外,除了用 Cursor 外,其它支持 MCP 调用的工具也是可以用的,自己做下配置就好,如:Trae、Cursor,甚至是 Cherry Studio:
修改后的设计稿:
以上就是这个MCP的基本用法,🤔 感觉很适合初期,没什么灵感时,让它来搭建基本的主体框架,然后自己再此基础上做精细化的调整。一些常规命令示例:
- create_rectangle:创建一个新的矩形。
- create_ellipse:创建一个新的椭圆或圆形。
- create_text:创建一个新的文本元素。
- create_frame:创建一个新的框架。
- set_fill_color:设置节点的填充颜色。
- set_stroke_color:设置节点的描边颜色。
- move_node:移动节点到新位置。
- resize_node:调整节点大小。
- set_font_name:设置文本节点的字体名称和样式。
- set_font_size:设置文本节点的字体大小。
- set_font_weight:设置文本节点的字体粗细。
- set_letter_spacing:设置文本节点的字母间距。
- set_line_height:设置文本节点的行高。
- set_paragraph_spacing:设置文本节点的段落间距。
别人分享的提示词
💁♂️ 有 生成HTML页面 需求的童鞋,可以在提示词里让 Cursor 直接生成对应代码,这是别处的看到的提示词:
获取Profile的所有信息,并根据设计稿信息进行开发
- 使用HTML,Tailwindcss
- 苹果、google等大厂设计配色风格
- 生成的文件保存到`figma-demo`目录下
- 无法下载的图片可以使用`export_node_as_image`生成或者使用unsplash
😶 没这个需求,就不尝试了,生成代码也是耗费点数的,Cursor Pro 一个月才500点,根本不够花,能省一点是一点🤷♀️。还看到一个更全提示词,也CV下,真正需要用到的时候参考着改就好了:
你是一名大厂资深UI/UX设计专家,拥有丰富的移动端应用设计经验,精通苹果人机界面设计指南。请帮我完成一款名为`百草集`iOS App的原型设计。请按照以下要求输出一套完整的高质量Figma APP原型图:
1. 设计目标
- 创建符合苹果人机界面指南(Human Interface Guidelines)的iOS原生风格设计
- 面向中草药爱好者和自然探索者,设计简洁直观的界面
- 确保原型图能直观展示APP的功能流程和用户体验
2. 用户需求分析
- 目标用户群体:对中草药、植物学、自然疗法感兴趣的用户,包括初学者和爱好者
- 用户痛点:缺乏系统化的中草药知识、难以识别野外植物及其药用价值、无法记录和整理自己的植物观察
- 用户期望:直观的植物识别功能、个性化学习路径和推荐、社区互动和知识分享
3. 功能规划
- 主页:提供快速访问草本图鉴、观察记录和社区的入口
- 草本图鉴:分类别展示中草药,配有详细图文介绍和音频讲解
- 观察记录:记录用户在野外的植物观察,支持拍照识别和地理位置标记
- 配方推荐:基于用户兴趣推荐草本配方和使用方法
- 社区互动:分享观察、交流经验、获取专业指导
- 设置:个人信息管理、通知设置等
4. 设计规范
- 使用最新的iOS设计元素和交互模式
- 遵循iPhone 6尺寸规格(宽度750px, 高度1624px)
- 采用自然、清新的配色方案,符合草本主题氛围
- 重视无障碍设计,确保文字对比度和交互区域大小合适
- 使用简洁清晰的图标和插图风格,融入自然元素
5. 原型图呈现要求
- 使用Figma创建所有设计稿
- 为每个功能设计一个到两个屏幕,如:登录/注册、主页、草本图鉴、观察记录、配方推荐、社区互动、设置
- 每行最多排列三个屏幕,之后换行继续展示
- 为每个屏幕添加设备边框和阴影,不要遮住屏幕内的内容
- 为每个屏幕添加简短说明,解释其功能和设计考虑
6. 关键用户旅程原型屏幕
- 6.1 登录/注册屏幕
- 功能:用户可以通过邮箱、手机号或社交媒体账号登录/注册
- 设计考虑:使用简洁的表单设计,提供快速登录选项,符合iOS设计规范
- 6.2 主页屏幕
- 功能:展示主要功能入口,包括草本图鉴、观察记录、配方推荐和社区动态
- 设计考虑:采用卡片式布局,突出视觉重点,使用自然色调
- 6.3 草本图鉴屏幕
- 功能:分类展示中草药,支持搜索和筛选
- 设计考虑:使用网格布局,提供清晰的视觉层次,支持图片预览
- 6.4 植物详情屏幕
- 功能:展示植物的详细信息,包括图片、文字介绍、音频讲解
- 设计考虑:采用上下滑动的单页布局,提供丰富的多媒体内容
- 6.5 观察记录屏幕
- 功能:记录用户的植物观察,支持拍照识别和地理位置标记
- 设计考虑:使用时间线布局,提供直观的记录展示方式
- 6.6 配方推荐屏幕
- 功能:基于用户兴趣推荐草本配方,支持收藏和分享
- 设计考虑:采用卡片式布局,突出配方的视觉吸引力
- 6.7 社区互动屏幕
- 功能:用户可以发布动态、浏览社区内容、与其他用户互动
- 设计考虑:使用流式布局,支持点赞、评论等社交互动
- 6.8 设计规范概述
- 配色方案:主色调为自然绿色(#4CAF50),辅助色为棕色(#795548)和黄色(#FFC107)
- 图标:采用简洁的线性图标风格,融入自然元素
- 无障碍设计:确保文字对比度符合WCAG 2.1标准,交互区域大小合适
- 动效:使用微妙的过渡动画,提升用户体验但不干扰主要功能
😄 设计效果看起还是挺不错的:
😏 Figma 免费版:适合个人或小型团队,支持无限文件存储,但只能创建3个项目,最多2人协作,版本历史仅保留30天,不能共享设计文件进行多人实时编辑,离线时无法使用。专业版:适合2人以上设计团队,取消项目和编辑者数量限制,版本历史无限,支持团队组件库、Slack集成、私人项目等高级协作功能,价格约12-16美元/月/人(年付较便宜),可按月或按年订阅。😄 限于篇幅,怎么 白嫖专业版 可以参见另外一篇文章~
来源:juejin.cn/post/7515231445276852239
antd 对 ai 下手了!Vue 开发者表示羡慕!
前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。
近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布推出 Ant Design X 1.0 🚀 ,这是一个基于 Ant Design 的全新 AGI 组件库,使用 React 构建 AI 驱动的用户交互变得更简单了,它可以无缝集成 AI 聊天组件和 API 服务,简化 AI 界面的开发流程。
该项目已在 Github 开源,拥有 1.6K Star!
看了网友的评论,看来大家还是需要的!当前的 Ant Design X 只支持 React 项目,看来 Vue 开发者要羡慕了...
ant-design-x 特性
- 🌈 源自企业级 AI 产品的最佳实践:基于 RICH 交互范式,提供卓越的 AI 交互体验
- 🧩 灵活多样的原子组件:覆盖绝大部分 AI 对话场景,助力快速构建个性化 AI 交互页面
- ⚡ 开箱即用的模型对接能力:轻松对接符合 OpenAI 标准的模型推理服务
- 🔄 高效管理对话数据流:提供好用的数据流管理功能,让开发更高效
- 📦 丰富的样板间支持:提供多种模板,快速启动 LUI 应用开发
- 🛡 TypeScript 全覆盖:采用 TypeScript 开发,提供完整类型支持,提升开发体验与可靠性
- 🎨 深度主题定制能力:支持细粒度的样式调整,满足各种场景的个性化需求
支持组件
以下圈中的部分为 ant-design-x 支持的组件。可以看到主要都是基于 AI Chat 场景的组件设计。现在你可以基于这些组件自由组装搭建一个自己的 AI 界面。
ant-design-x 也提供了一个完整 AI Chat 的 Demo 演示,可以查看 Demo 的代码并直接使用。
更多组件详细内容可参考 组件文档
使用
以下命令安装 @ant-design/x
依赖。
注意,ant-design-x 是基于 Ant Design,因此还需要安装依赖 antd
。
yarn add antd @ant-design/x
import React from 'react';
import {
// 消息气泡
Bubble,
// 发送框
Sender,
} from '@ant-design/x';
const messages = [
{
content: 'Hello, Ant Design X!',
role: 'user',
},
];
const App = () => (
<div>
<Bubble.List items={messages} />
<Sender />
</div>
);
export default App;
Ant Design X 前生 ProChat
不知道有没有小伙伴们使用过 ProChat,这个库后面的维护可能会有些不确定性,其维护者表示 “24 年下半年后就没有更多精力来维护这个项目了,Github 上的 Issue 存留了很多,这边只能尽量把一些恶性 Bug 修复
”
如上所示,也回答了其和 Ant Design X 的关系:ProChat 是 x 的前生,新用户请直接使用 x,老用户也请尽快迁移到 x
。
感兴趣的朋友们可以去试试哦!
来源:juejin.cn/post/7444878635717443595
基于Flutter实现的小说阅读器——BITReader ,相信我你也可以变成光!

前言
最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼瞪小眼了。之后在 FlutterCandies里面咨询了群友,发现了一种使用外部提供书籍数据源的方法可以避免数据来源挂掉,说干就干vscode启动!
项目地址
项目介绍
当前功能包含:
- 源搜索:使用内置数据来源进行搜索数据(后续更新:用户可以自行导入来源进行源搜索
- 收藏书架
- 阅读历史记录
- 阅读设置:字号设置,字体颜色更改,自定义阅读背景(支持调色板自定义选择,支持image设置为背景
- 主题设置:支持九种颜色的主题样式
- 书籍详情:展示书籍信息以及章节目录等书籍信息
支持平台
平台 | 是否支持 |
---|---|
Android | ✅ |
IOS | ✅ |
Windows | ✅ |
MacOS | ✅ |
Web | ❌ |
Linux | ❌ |
项目截图





mac运行截图

windows运行截图

项目结构
lib
├── main.dart -- 入口
├── assets -- 本地资源生成
├── base -- 请求状态、页面状态
├── db -- 数据缓存
├── icons -- 图标
├── net -- 网络请求、网络状态
├── n_pages
├── detail -- 详情页
├── home -- 首页
├── search -- 全网搜索搜索页
├── history -- 历史记录
├── read -- 小说阅读
└── like -- 收藏书架
├── pages 已废弃⚠
├── home -- 首页
├── novel -- 小说阅读
├── search -- 全网搜索
├── category -- 小说分类
├── detail_novel -- 小说详情
├── book_novel -- 书架、站源
└── collect_novel -- 小说收藏
├── route -- 路由
└── theme -- 主题管理
└── themes -- 主题颜色-9种颜色
├── tools -- 工具类 、解析工具、日志、防抖。。。
└── widget -- 自定义组件、工具 、加载、状态、图片 等。。。。。。
阅读器主要包含的模块
- 阅读显示:文本解析,对文本进行展示处理
- 数据解析: 数据源的解析,以及数据来源的解析(目前只支持简单数据源格式解析、后续可能会更新更多格式解析
- 功能:阅读翻页样式、字号、背景、背景图、切换章节、收藏、历史记录、本地缓存等
阅读显示
阅读文本展示我用的是extended_text因为支持自定义效果很好。
实现的效果把文本中 “ ” 引用起来的文本自定义成我自己想要的效果样式。
class MateText extends SpecialText {
MateText(
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.showAtBackground = false,
required this.start,
required this.color,
}) : super(flag, '”', textStyle, onTap: onTap);
static const String flag = '“';
final int start;
final Color color;
/// whether show background for @somebody
final bool showAtBackground;
@override
InlineSpan finishText() {
final TextStyle textStyle =
this.textStyle?.copyWith(color: color) ?? const TextStyle();
final String atText = toString();
return showAtBackground
? BackgroundTextSpan(
background: Paint()..color = Colors.blue.withOpacity(0.15),
text: atText,
actualText: atText,
start: start,
///caret can move int0 special text
deleteAll: true,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}))
: SpecialTextSpan(
text: atText,
actualText: atText,
start: start,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}));
}
}
class NovelSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
NovelSpecialTextSpanBuilder({required this.color});
Color color;
set setColor(Color c) => color = c;
@override
SpecialText? createSpecialText(String flag,
{TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index}) {
if (flag == '') {
return null;
} else if (isStart(flag, AtText.flag)) {
return AtText(
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
color: color,
);
} else if (isStart(flag, MateText.flag)) {
return MateText(
textStyle,
onTap,
start: index! - (MateText.flag.length - 1),
color: color,
);
}
// index is end index of start flag, so text start index should be index-(flag.length-1)
return null;
}
}
数据解析编码格式转换
首先数据是有不同的编码格式,否则我们直接展示可能会导致乱码问题。
先把数据给根据查找到的编码类型来做单独的处理转换。
/// 解析html数据 解码 不同编码
static String parseHtmlDecode(dynamic htmlData) {
String resultData = gbk.decode(htmlData);
final charset = ParseSourceRule.parseCharset(htmlData: resultData) ?? "gbk";
if (charset.toLowerCase() == "utf-8" || charset.toLowerCase() == "utf8") {
resultData = utf8.decode(htmlData);
}
return resultData;
}
static String? parseCharset({
required String htmlData,
}) {
Document document = parse(htmlData);
List<Element> metaTags = document.getElementsByTagName('meta').toList();
for (Element meta in metaTags) {
String? charset = meta.attributes['charset'];
String content = meta.attributes['content'] ??
""; //<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
if (charset != null) {
return charset;
}
List<String> parts = content.split(';');
for (String part in parts) {
part = part.trim();
if (part.startsWith('charset=')) {
return part.split('=').last.trim();
}
}
}
return null;
}
数据结构解析-代码太多只展示部分
Document document = parse(htmlData);
//
List<Element> rootNodes = [];
if (rootSelector != null && rootSelector.isNotEmpty) {
//
List<String> rootParts = rootSelector.split(RegExp(r'[@>]'));
String initialPart = rootParts[0].trim();
//
if (initialPart.startsWith('class.')) {
String className = initialPart.split('.')[1];
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('.')) {
String className = initialPart.substring(1);
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('#')) {
String idSelector = initialPart.substring(1);
rootNodes = document.querySelectorAll('#$idSelector').toList();
} else if (initialPart.startsWith('id.')) {
String idSelector = initialPart.split('.')[1];
var element = document.querySelector('#$idSelector');
if (element != null) {
rootNodes.add(element);
}
} else if (initialPart.contains(' ')) {
String idSelector = initialPart.replaceAll(' ', ">");
var element = document.querySelector(idSelector);
if (element != null) {
rootNodes.add(element);
}
} else {
rootNodes = document.getElementsByTagName(initialPart).toList();
}
存储工具类 - 部分代码
/// shared_preferences
class PreferencesDB {
PreferencesDB._();
static final PreferencesDB instance = PreferencesDB._();
SharedPreferencesAsync? _instance;
SharedPreferencesAsync get sps => _instance ??= SharedPreferencesAsync();
/*** APP相关 ***/
/// 主题外观模式
///
/// system(默认):跟随系统 light:普通 dark:深色
static const appThemeDarkMode = 'appThemeDarkMode';
/// 多主题模式
///
/// default(默认)
static const appMultipleThemesMode = 'appMultipleThemesMode';
/// 字体大小
///
///
static const fontSize = 'fontSize';
/// 字体粗细
static const fontWeight = 'fontWeight';
/// 设置-主题外观模式
Future<void> setAppThemeDarkMode(ThemeMode themeMode) async {
await sps.setString(appThemeDarkMode, themeMode.name);
}
/// 获取-主题外观模式
Future<ThemeMode> getAppThemeDarkMode() async {
final String themeDarkMode =
await sps.getString(appThemeDarkMode) ?? 'system';
return darkThemeMode(themeDarkMode);
}
/// 设置-多主题模式
Future<void> setMultipleThemesMode(String value) async {
await sps.setString(appMultipleThemesMode, value);
}
/// 获取-多主题模式
Future<String> getMultipleThemesMode() async {
return await sps.getString(appMultipleThemesMode) ?? 'default';
}
/// 获取-fontsize 大小 默认18
Future<double> getNovelFontSize() async {
return await sps.getDouble(fontSize) ?? 18;
}
/// 设置 -fontsize 大小
Future<void> setNovelFontSize(double size) async {
await sps.setDouble(fontSize, size);
}
/// 设置-多主题模式
Future<void> setNovelFontWeight(NovelReadFontWeightEnum value) async {
await sps.setString(fontWeight, value.id);
}
/// 获取-多主题模式
Future<String> getNovelFontWeight() async {
return await sps.getString(fontWeight) ?? 'w300';
}
}
最后
特别鸣谢FlutterCandies糖果社区,也欢迎加入我们的大家庭。让我们一起学习共同进步
免责声明:本项目提供的源代码仅用学习,请勿用于商业盈利。
来源:juejin.cn/post/7433306628994940979
Cursor生成UI,加一步封神
用 Cursor 做 UI,有两种最简单又有效的方法,一个免费一个付费,不管你要做网页 UI 还是应用程序 UI,都能用。
我这里不推荐直接用 Cursor 自带模型生成 UI,模型生成出来的效果比较差,就算是最强的 Claude 也不太行。
本文我分享的方法是我最近学到的,先说免费的。当我们手头有一张 UI 图片时,不要直接丢给 Cursor,而是先用 Google 的 Gemini 模型、Claude 或者 ChatGPT,这里我用的是 Gemini 并打开 Canvas 功能。
我把 UI 图片放到 Gemini 中,然后让它根据 UI 截图生成一份 JSON 格式的设计规范文件。
提示词参考:
Create a JSON-formatted design system profile. This profile should extract relevant visualdesign information from the provided screenshots. The JSON output must specifically include:
The overarching design style (e.g., color palette, typography, spacing, visual hierarchy).The structural elements and layout principles.Any other attributes crucial for an Al to consistently replicate these design systems.Crucially, do not include the specific content or data present within the images, focusing solely
生成出来的 JSON 包含整体设计风格、结构元素、布局原则,以及一些关键属性。
接着把这份 JSON 文件复制到 Cursor 中,让 Cursor 根据这份 JSON 来生成代码。
提示词参考:
参考 @design.json 设计规范,根据图片中的样式,生成一个网页。
生成效果如下:
对比一下如果直接用 Cursor 根据截图生成代码,不用 JSON 文件。
提示词:
按照图片中的UI样式,创建一个新的页面。注意:尽可能按照图片中的样子创建!!!
效果如下:
可以看到,效果差了很多,我原型 UI 的截图如下:
这是我随便找的一张图片作为例子,可以明显看出,先提取一份 JSON 文件,然后再让 Cursor 生成代码,效果要好很多。
为什么这种先提取 JSON 文件再生成代码的方法很有效?因为当任务涉及精确、结构化、无歧义的数据时,JSON 让模型理解更清晰,处理更高效,生成的结果也更稳定。
以上就是免费的方法。
接下来是付费的方法。
如果你对 UI 要求比较高,比如需要反复修改,那我推荐直接用 v0 API
。v0 模型是 Vercel 推出的,专门针对 UI 和前端开发优化,所以在处理这类任务时,v0 比 Claude、Gemini、ChatGPT 都更强。
我一般会在需要大量生成 UI 时订阅 v0,一个月 20 美金,这个月把需要的 UI 全部生成完,然后就可以退订。
订阅后去后台生成 API Key,然后在 Cursor 中调用 v0 模型即可。
在 Cursor 模型设置中,把 v0 的 API Key 填进去,v0 模型是符合 OpenAI API 规范的,所以直接选择 OpenAI 模型即可。
实际使用时,你在对话中用的是 OpenAI 模型,但后台用的其实是 v0 模型。
好了,这就是免费和付费的两种方法。
最后再推荐两个动画工具:Framer Motion 和 React Bits,也都是很棒的选择。
你可以把 React Bits 中动画代码直接粘贴到 Cursor 中,让模型帮你集成即可。
- React:相当于项目经理和架构师
- Radix UI:相当于功能工程师
- Tailwind CSS:相当于视觉设计师
- Framer Motion:相当于动效设计师
以上就是一套现代强大 UI 开发工具箱,大家可以根据需要组合使用!
来源:juejin.cn/post/7519407199765987343
Vue3.5正式上线,父传子props用法更丝滑简洁
前言
Vue3.5
在2024-09-03
正式上线,目前在Vue
官网显最新版本已经是Vue3.5
,其中主要包含了几个小改动,我留意到日常最常用的改动就是props
了,肯定是用Vue3
的人必用的,所以针对性说一下props
的两个
小改动使我们日常使用更加灵活。
一、带响应式Props解构赋值
简述: 以前我们对Props
直接进行解构赋值是会失去响应式的,需要配合使用toRefs
或者toRef
解构才会有响应式,那么就多了toRefs
或者toRef
这工序,而最新Vue3.5
版本已经不需要了。
这样直接解构,testCount能直接渲染显示,但会失去响应式,当我们修改testCount时页面不更新。
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});
const { testCount } = props;
</script>
保留响应式的老写法,使用
toRefs
或者toRef
解构
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps, toRef, toRefs } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 0,
},
});
const { testCount } = toRefs(props);
// 或者
const testCount = toRef(props, 'testCount');
</script>
最新
Vue3.5
写法,不借助”外力“直接解构,依然保持响应式
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const { testCount } = defineProps({
testCount: {
type: Number,
},
});
</script>
相比以前简洁了真的太多,直接解构使用省去了toRefs
或者toRef
二、Props默认值新写法
简述: 以前默认值都是用default: ***
去设置,现在不用了,现在只需要解构的时候直接设置默认值,不需要额外处理。
先看看旧的
default: ***
默认值写法
如下第12
就是旧写法,其它以前Vue2
也是这样设置默认值
<template>
<div>
{{ props.testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
testCount: {
type: Number,
default: 1
},
});
</script>
最新优化的写法
如下第9
行,解构的时候直接一步到位设置默认值,更接近js
语法的写法。
<template>
<div>
{{ testCount }}
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const { testCount=18 } = defineProps({
testCount: {
type: Number,
},
});
</script>
小结
这次更新其实props
的本质功能并没有改变,但写法确实变的更加丝滑好用了,props
使用非常高频感觉还是有必要跟进这种更简洁的写法。如果那里写的不对或者有更好建议欢迎大佬指点啊。
来源:juejin.cn/post/7410333135118090279
油猴+手势识别:我实现了任意网页隔空控制!
引言
最近我的小册《油猴脚本实战指南》上线了,很多同学都很感兴趣。
有些人学习后就私下问我,油猴既然能将任意前端js注入到当前网页中,是否能结合手势识别实现任意网页隔空控制,实现类似手机上的隔空翻页功能呢?
这是个非常好的想法,于是,我经过研究,将它实现出来了!先看看脚本效果:
1️⃣ 上下翻页功能
- 左手张开,右手可以控制网页向下翻页
- 左手握拳,右手可以控制网页向上翻页
2️⃣ 右手可以控制一个模拟光标移动
3️⃣ 右手握拳,实现点击效果
当然,还预设了很多手势,比如双手比✌🏻关闭当前网页,左手竖起大拇指,右手实现缩放网页等效果。
实现原理
其实实现原理非常简单,就是油猴+手势识别
油猴Tampermonkey
油猴(Tampermonkey)是一款浏览器插件,允许用户在网页加载时注入自定义的 JavaScript 脚本,来增强、修改或自动化网页行为。
通俗地说,借助油猴,你可以将自己的 JavaScript 代码“植入”任意网页,实现自动登录、抢单、签到、数据爬取、广告屏蔽等各种“开挂级”功能,彻底掌控页面行为。
如果你想深入了解,可以参考文章:juejin.cn/book/751468…
手势识别MediaPipe
手势识别其实已经不是一个新鲜词了,随着大模型的普及,AI识别手势非常简单方便。本示例中使用的AI模型识别,主要依赖了谷歌的MediaPipe。
MediaPipe 解决方案提供了一套库和工具,可帮助您快速在应用中应用人工智能 (AI) 和机器学习 (ML) 技术。
本示例中的demo就是借助它的手势识别能力实现的。在web中,我们可以借助MediaPipe @mediapipe/tasks-vision
NPM 软件包获取手势识别器代码。
MediaPipe @mediapipe/tasks-vision
它的使用也非常简单
// Create task for image file processing:
const vision = await FilesetResolver.forVisionTasks(
// path/to/wasm/root
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm "
);
const gestureRecognizer = await GestureRecognizer.createFromOptions(vision, {
baseOptions: {
modelAssetPath: "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task"
},
numHands: 2
});
如何将两者结合
借助油猴的脚本注入能力,我们能让我们的手势识别代码运行在任意网页,从而轻松实现隔空手势控制效果。
当然,脚本运行时必须开启摄像机权限,页面其实会有一个画面,但是很尴尬,于是实际脚本中,我将画面隐藏了。
手势识别的原理其实也不复杂,通过tasks-vision,我们可以拿到上图中各个关键的点的位置信息,通过判断不同点位之间的距离,实现不同的手势判断。
// 判定手势
// 手掌张开手势
function isHandOpen(hand) {
const fingers = [[8, 5], [12, 9], [16, 13], [20, 17]];
return fingers.filter(([tip, base]) => dist(hand[tip], hand[base]) > 0.1).length >= 4;
}
// 握拳手势
function isFist(hand) {
const fingers = [[8, 5], [12, 9], [16, 13], [20, 17]];
return fingers.filter(([tip, base]) => dist(hand[tip], hand[base]) < 0.06).length >= 3;
}
// 胜利手势
function isVictory(hand) {
const extended = [8, 12];
const folded = [16, 20];
return (
extended.every((i) => dist(hand[i], hand[i - 3]) > 0.1) &&
folded.every((i) => dist(hand[i], hand[i - 3]) < 0.05)
);
}
上述代码中的hand就是mediapipe/tasks-vision
返回的手势信息。结合这些自定义的手势信息,我们就能实现各种花里胡哨的功能!
进一步学习
对于手势识别的学习,我们可以去学习官方的demo,在npmjs上,我们可以找到使用说明
这个包人脸识别、手势识别等非常多的功能,非常强大!
如果你对油猴脚本感兴趣,可以看看教程 《 油猴脚本实战指南 》, 本示例中的demo也会在这个教程中详细讲解。
当然,你也可以加我
shc1139874527
,我会拉你进学习交流群,一起体验油猴脚本开发的魅力!
来源:juejin.cn/post/7521250468267360307
三个请求,怎么实现a、b先发送,c最后发送
方案一:使用 Promise.all 控制并发
最直接的方法是使用Promise.all
并行处理 A 和 B,待两者都完成后再发送 C。
async function fetchData() {
try {
// 同时发送请求A和请求B
const [resultA, resultB] = await Promise.all([
fetchRequestA(), // 假设这是你的请求A函数
fetchRequestB() // 假设这是你的请求B函数
]);
// 请求A和B都完成后,发送请求C
const resultC = await fetchRequestC(resultA, resultB); // 请求C可能依赖A和B的结果
return resultC;
} catch (error) {
console.error('请求失败:', error);
throw error;
}
}
优点:实现简单,代码清晰
缺点:如果请求 C 不依赖 A 和 B 的结果,这种方式会增加不必要的等待时间
方案二:手动管理 Promise 并发
如果请求 C 不依赖 A 和 B 的结果,可以让 C 在 A 和 B 开始后立即发送,但在 A 和 B 都完成后再处理 C 的结果。
async function fetchData() {
try {
// 立即发送请求A、B、C
const promiseA = fetchRequestA();
const promiseB = fetchRequestB();
const promiseC = fetchRequestC();
// 等待A和B完成(不等待C)
const [resultA, resultB] = await Promise.all([promiseA, promiseB]);
// 此时A和B已完成,获取C的结果(无论C是否已完成)
const resultC = await promiseC;
return { resultA, resultB, resultC };
} catch (error) {
console.error('请求失败:', error);
throw error;
}
}
优点:C 的执行不会被 A 和 B 阻塞,适合 C 不依赖 A、B 结果的场景
缺点:代码复杂度稍高,需要确保 C 的处理逻辑确实不需要 A 和 B 的结果
方案三:使用自定义并发控制器
对于更复杂的并发控制需求,可以封装一个通用的并发控制器。
class RequestController {
constructor() {
this.runningCount = 0;
this.maxConcurrency = 2; // 最大并发数
this.queue = [];
}
async addRequest(requestFn) {
// 如果达到最大并发数,将请求放入队列等待
if (this.runningCount >= this.maxConcurrency) {
await new Promise(resolve => this.queue.push(resolve));
}
this.runningCount++;
try {
// 执行请求
const result = await requestFn();
return result;
} finally {
// 请求完成,减少并发计数
this.runningCount--;
// 如果队列中有等待的请求,取出一个继续执行
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
}
}
}
// 使用示例
async function fetchData() {
const controller = new RequestController();
// 同时发送A和B(受并发数限制)
const promiseA = controller.addRequest(fetchRequestA);
const promiseB = controller.addRequest(fetchRequestB);
// 等待A和B完成
await Promise.all([promiseA, promiseB]);
// 发送请求C
const resultC = await fetchRequestC();
return resultC;
}
优点:灵活控制并发数,适用于更复杂的场景
缺点:需要额外的代码实现,适合作为工具类复用
选择建议
- 如果 C 依赖 A 和 B 的结果,推荐方案一
- 如果 C 不依赖 A 和 B 的结果,但希望 A 和 B 先完成,推荐方案二
- 如果需要更复杂的并发控制,推荐方案三
来源:juejin.cn/post/7513069939974225957
🤡什么鬼?两行代码就能适应任何屏幕?
你可能想不到,只用两行 CSS,就能让你的卡片、图片、内容块自动适应各种屏幕宽度,彻底摆脱复杂的媒体查询!
秘诀就是 CSS Grid 的 auto-fill
和 auto-fit
。
马上教你用!✨
🧩 基础概念
假设你有这样一个需求:
- 一排展示很多卡片
- 每个卡片最小宽度 200px,剩余空间平均分配
- 屏幕变窄时自动换行
只需在父元素加两行 CSS 就能实现:
/* 父元素 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* 子元素 */
.item {
height: 200px;
background-color: rgb(141, 141, 255);
border-radius: 10px;
}
下面详细解释这行代码的意思:
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
这是 CSS Grid 布局里定义列宽的常用写法,逐个拆解如下:
1. grid-template-columns
- 作用:定义网格容器里有多少列,以及每列的宽度。
2. repeat(auto-fit, ...)
repeat
是个重复函数,表示后面的模式会被重复多次。auto-fit
是一个特殊值,意思是:自动根据容器宽度,能放下几个就放几个,每列都用后面的规则。
- 容器宽度足够时,能多放就多放,放不下就自动换行。
3. minmax(200px, 1fr)
minmax
也是一个函数,意思是:每列最小200px,最大可以占1fr(剩余空间的平分)- 具体来说:
- 当屏幕宽度很窄时,每列最小宽度是200px,再窄就会换行。
- 当屏幕宽度变宽,卡片会自动拉伸,每列最大可以占据剩余空间的等分(
1fr
),让内容填满整行。
4. 综合起来
- 这行代码的意思就是:
- 网格会自动生成多列,每列最小200px,最大可以平分一行的剩余空间。
- 屏幕宽了就多显示几列,屏幕窄了就少显示几列,自动换行,自适应各种屏幕!
- 不需要媒体查询,布局就能灵活响应。
总结一句话:
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
让你的网格卡片最小200px,最大自动填满一行,自动适应任何屏幕,布局永远美观!
这里还能填 auto-fill
,和 auto-fit
有啥区别?
🥇 auto-fill 和 auto-fit 有啥区别?
1. auto-fill
🧱 尽可能多地填充列,即使没有内容也会“占位”
- 会自动创建尽可能多的列轨道(包括空轨道),让网格尽量填满容器。
- 适合需要“列对齐”或“固定网格数”的场景。
2. auto-fit
🧱 自动适应内容,能合并多余空列,不占位
- 会自动“折叠”没有内容的轨道,让现有的内容尽量拉伸占满空间。
- 适合希望内容自适应填满整行的场景。
👀 直观对比
假设容器宽度能容纳 10 个 200px 的卡片,但你只放了 5 个卡片:
auto-fill
会保留 10 列宽度,5 个卡片在前五列,后面五列是“空轨道”。auto-fit
会折叠掉后面五列,让这 5 个卡片拉伸填满整行。
👇 Demo 代码:
<h2>auto-fill</h2>
<div class="grid-fill">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>
<h2>auto-fit</h2>
<div class="grid-fit">
<div>item1</div>
<div>item2</div>
<div>item3</div>
<div>item4</div>
<div>item5</div>
</div>
.grid-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 40px;
}
.grid-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.grid-fill div {
background: #08f700;
}
.grid-fit div {
background: #f7b500;
}
.grid-fill div,
.grid-fit div {
padding: 24px;
font-size: 18px;
border-radius: 8px;
text-align: center;
}
兼容性
🎯 什么时候用 auto-fill,什么时候用 auto-fit?
- 希望每行“有多少内容就撑多宽”,用
auto-fit
适合卡片式布局、相册、响应式按钮等。 - 希望“固定列数/有占位”,用
auto-fill
比如表格、日历,或者你希望网格始终对齐,即使内容不满。
📝 总结
属性 | 空轨道 | 内容拉伸 | 适用场景 |
---|---|---|---|
auto-fill | 保留 | 否 | 固定列数、占位网格 |
auto-fit | 折叠 | 是 | 流式布局、拉伸填充 |
🌟 小结
auto-fill
更像“占位”,auto-fit
更像“自适应”- 推荐大部分响应式卡片用
auto-fit
- 善用
minmax
配合,让列宽自适应得更自然
只需两行代码,你的页面就能优雅适配各种屏幕!
觉得有用就点赞收藏吧,更多前端干货持续更新中!🚀✨
来源:juejin.cn/post/7497895954101403688
🚀惊了,这个国产软件居然这么牛,比 uniapp 还全能❤️
最近跟同事闲聊,大家都在吐槽一个问题:
!
App 是越做越像平台了,但开发却越做越痛苦了。
你想加个活动页,产品说今晚上线;
你想做个业务扩展,运营说要不你再写个低代码工具;
你想适配鸿蒙,领导说最好做个 React Native
得了;
同事活成了“加班工具人”,App 也做成了臃肿的 “功能集成器”。
难道开发一个通用的 App
,就非得这么累吗?
于是,我们试着去找更轻、更灵活的解决方案。
我们只是想做一个“活动页托管方案”,不想每次上线都发版,更不想因为临时需求牵扯整个开发团队。
但随着调研的深入,我们发现这种痛点其实根本不是“活动页”本身,而是:App
缺乏一个**“包容性很强的容器”**。
比如:
- 新功能不用频繁发版;
- 能复用现有页面或者组件;
- 可以独立上线,不干扰主应用。
我们对比了几个方向:
WebView + H5
:快是快,但弱得可怕,尤其是 JSBridge 管理地狱,体验不佳;- 低代码平台:适合特定场景,但定制性不足,复杂页面性能堪忧;
RN/Flutter
微模块化:维护成本太高,涉及太多客户端改动。
直到我在调研中遇到了 FinClip,才意识到这事完全可以换个方式。
让人眼前一亮的 FinClip
FinClip
是什么?
一句话说完:把小程序能力,通用化、标准化,塞进任何 App。
从技术架构来说,FinClip
提供的是一个极其轻量的小程序容器 SDK(3MB都不到),可以直接嵌进你的 iOS
、Android
、HarmonyOS
App,甚至 React Native
、Flutter
、macOS
、车机都能跑。
开发者只要写一套小程序代码,放进去就能运行。不用重新适配底层系统,也不用改框架结构。
而且它兼容微信/支付宝/抖音小程序语法,意味着你过去写的项目,可能几乎零改动就能跑起来。
于是,我们立刻拉了群,软磨硬泡,搞来了二组同事开发的活动页项目,
需要的同学请戳这里:github.com/FernAbby/H5…
然后通过 FinClip Studio
打包上传,再嵌入 App
。
FinClip Studio
,真的有点香
讲真,刚开始用 FinClip Studio
,我也做好了“将就一下”的心理准备。
结果没想到是真香警告。
首先,新建项目一键生成模板,跟微信小程序开发工具 99% 像;
你也可以和我一样选择导入已有的项目,
其次,模拟器支持多终端调试,拖拉缩放,全程无需真机;
另外,发布打包一条龙服务,你只需要上传至云端后台:
输入相关上传信息:
等待上传成功即可!
后台是平台运营的“指挥中心”
接下来的重头戏,需要我们登陆后台系统,
一个超级应用不是靠开发者单打独斗,而是靠多个角色协同。FinClip
的后台做得非常细腻,功能齐全,不管是开发还是运维同学,都可以轻松驾驭!
小程序管理模块,不仅可以新建、管理前面上传的小程序,还可以体验预览版、发布审核;
首先,在隐私设置栏目里设置隐私保护指引:
然后我们就可以配置审核版本或者体验版本了!
接着我们就可以直接跳转到待办中心通过审核!
除此之外,常用的灰度发布、权限范围、自动上架全都支持;
数据分析清晰易读,不需要 BI 工具也能看懂;
让你不再为如何做好运维而发愁!
用了一周的真实感受
流程
使用一周多了,整体的流程是这样的:
- 本地写代码,IDE 模拟器预览;
- 上传代码,后台提交审核;
- 设置灰度策略,用户扫码体验;
- 最终发布上线。
优点
我们没改动原生代码,甚至没有重新接入任何 SDK
,只是增加一个容器模块 + 几行配置。
团队有个原来的 RN 老项目,直接用 FinClip
的容器跑起来,居然都不用重写,兼容度真的惊人。
缺点
但是缺点也有:
比如,导入已有项目会进行检测,并且明确的告知用户,其实可以后台默认执行,用户体验会更好!
另外最主要的是,后台和编辑器的登陆状态是临时的,不会长期保持!每次登陆挺麻烦的
彩蛋
首先,FinClip
贴心的内置了 AI
助手,你使用过程遇到的任何问题都可以在这里找到答案!
最重要的是,FinClip
提供了基于接口的 AI
能力,可以通过 RAG
技术为小程序注入了智能化能力,涵盖内容生成、上下文交互、数据分析等多方面功能。
这不仅提升了用户体验,也为开发者提供了便捷的 AI
集成工具,进一步增强了 FinClip
生态的竞争力!
总结
如果再给我造一次 App 的机会,我一定毫不犹豫地选择 FinClip
!
当我们从“做功能”切换到“建生态”,思路就会完全不一样:
- App 不再是“巨石应用”,而是一个个业务模块的拼图
- 小程序就像“微服务 UI 化”,能独立更新、上线、下架
- 技术架构也从“一体化耦合”变成“解耦 + 动态加载”
而 FinClip
帮助开发者从“重复搬砖” 变成 “生态平台管理员”!
如果你也有和我一样的困惑,你也可以试试:
- 把一个已有的活动页,用 FinClip 打包成小程序;
- 嵌进你现有 App 中,再用 FinClip Studio 发布版本;
- 后台配置白名单,手机扫码预览。
1 天内,你就能体验一把“做平台”的感觉。
时代正在变化。我们不该再为“发布一个功能”耗尽精力,而应该把更多时间留给真正重要的东西 —— 创新、体验、增长。
FinClip 不只是工具,更是重构开发者角色的机会。
你准备好了吗?
来源:juejin.cn/post/7493798605658816553
老板让我弄3d,从0学建模😂
blender导出的轿车🚗.glb
:
最近因为有需求,不得不搞起3d这一块,说到3d,以为是学一个
threejs
就够了,结果是blender
也要学。
blender
可能有的前端开发或者后端开发没了解过,简单得说就是捏3d
模型的这么一个东西。
经常听人家说建模建模
,就是这个东西来着。
下载下来就是这么一个软件👇🏻:
通过对blender
的学习可以做很多东西,那blender
究竟可以做什么。要想知道能做什么,就要先知道blender
是个啥。
blender
是一个永久开源且免费的三维创作软件
,支持建模
、雕刻
、骨骼
、装配
、动画模拟实时渲染合成和运动跟踪等等三维创作
。
推荐一下大家一些现成的模型网站或插件或者材质贴图等:
- sketchfab.com
- cubebrush.co
- cgmodel.com
- free3d.co
- blender.org/download/de…
- blendermarket.com
- blender.kim/
- github.com/poly-haven/…
🔴 入门
⭕︎ 课程内容与目标
- 学习基本设置、模型变换、建模、UV编辑、材质与贴图、渲染等核心流程
- 掌握独立制作初级3D模型的能力
核心学习点
观察
-> 辅助建模,提供更好的视觉策略
变换
-> 基本变化,实现移动、旋转、复制图像等移动策略
建模
-> 重塑多边形,杜绝线建模障碍
修改器
-> 提供更便捷的迭代可能性
uv
-> 纹理图层的映射到表面的方法
材质
-> 基本材质的属性设置,只需参照别人设置的方式即可
渲染
-> 了解基本的渲染设置,灯光
不要被界面中无关的设置项影响。每个三维软件都是复杂的,但是目的只是为了满足不同人的不同需求。使用时,只需要按照方法简单设置一些需要的参数即可。别的参数默认即可。
如果你看文字太多,觉得烦躁,那就记得:没有太多欲望的话,我们目的就是实现建模,表现它的材质,把它渲染出来就可以了。三点:建模
->材质
->渲染
。
⭕︎ Blender核心优势
- 轻量化设计:相比传统3D软件更轻便快捷
- 开源免费:完全免费且持续更新
- 社区生态:
- 开放社区支持
- 原生支持GLB等现代格式
- 丰富插件生态
- 发展前景:在开源3D工具中处于领先地位
⭕︎ 基础设置指南
- 软件下载
官方下载链接:http://www.blender.org/download/ - 中文设置
路径:偏好设置 > 界面 > 翻译 > 勾选"中文(简体)" - 默认间隔多久保存 : 可设置。不怕断电、崩溃、找不到正在做而没有保存到文件。
⭕︎ 视口操作
快捷键ESC下面的波浪键,英文模式下:
flowchart TD
B[基本视图控制]
B --> B1[旋转视图]
B1 -->|操作方式| 鼠标中键
B1 -->|效果| 围绕视点中心旋转
B --> B2[平移视图]
B2 -->|操作方式| Shift+鼠标中键
B2 -->|效果| 平移观察视角
B --> B3[缩放视图]
B3 -->|操作方式1| 滚动鼠标中键
B3 -->|操作方式2| Shift+B框选缩放
B --> B4[快捷键ESC下面的波浪键,英文模式下]
style B fill:#4b8bf5
🔴 基础操作
⭕︎ 语言的设置
⭕︎ 场景设置单位
⭕︎ 文件栏
文件 - 编辑 - 渲染 - 窗口 - 帮助
⭕︎ 工作台
比如说uv
编辑器:
比如说贴图
:
比如说着色器
:
比如编辑多边形
的工具台:
⭕︎ 快捷键操作
按住鼠标中键 -> 旋转
按住鼠标中键 + shift -> 平移
鼠标中键滚动 -> 放大缩小
⭕︎ 不同视口查看
切换四格图:
shift+a
创建一个网格:
ctrl+alt+q
切换成四格图,同样再按一遍就是退出四格图。
如果需要查看更多的视图,也可以按一下Tab
上面的波浪键,像这样:
(按住左键
长按选中某个物体,可以单独查看选中物体的视图。)
接下来看一下这些视图的小图标,具体代表什么,如果有不太会的(大家可以鼠标悬浮在图标上面,它会给出具体的提示,然后大家可以每个小图标点一下试一试,不用害怕软件会崩盘,怎么弄软件都不会出事,自己可以多研究研究,即使崩了也可以重下载,放心大胆去试):
一个是叠加层
:可以添加线框,统计信息等辅助观察。
一个是视图着色方式。
🔴 基本体
点击文件
->新建
->常规
,之前的文件看需求看看要不要保存。
默认会出现一个立方体
,我们按x
键,它会提示我们要删除
这个物体吗?我们先删掉这个立方体。
上面我们说过,shift+a
可以弹出一个面板:
这样子,我们先创建一个立方体
,网格
+立方体
。
游标
(在游标模式下,可以任意拖动游标):
或者在选择
模式下,按住shift+右键
也是可以拖动游标的。
拖动游标,然后去新建一个立方体,我们会发现物体会创建在以游标
为中心的位置。所以我们去创建一个物体,首先先要把游标的位置给设好,创新物体就会直接在游标那个位置了。
物体的设置面板:
选择某些或者某个物体,按住左键进行框选即可。
有时候选择的时候会发现框选住的,有一个是红的,一个是黄的。黄的是后加选上的,可以作为移动物体这样子。
按a
键就是全选。ctrl + i
就是反选。shift
是加选。
🔴 基本变换
- 基础操作:
鼠标中键
:旋转视图
shiftA
:新建立方体
shift+中键
:上下,左右移动视图
鼠标滚轮
:放大缩小视图
G
:移动
物体 GX/GY/GZ=(沿着x、y、z轴移动)
R
:旋转
物体 RX/RY/RZ=(沿着x、y、z轴旋转)
S
:缩放
物体 SX/SY/SZ=(沿着x、y、z轴缩放) - 设置界面布局,保存窗口布局.
- 小键盘“0”摄像机视角
- “N”收放右边菜单栏
纸上得来终觉浅
,我们还是得多动手去尝试尝试,就算是做一个小物件小物体,前期也会觉得会有满满的成就感,用某个操作键的知识特定得做一个小小练习。
⭕︎ 对齐、捕捉、复制
选中圆锥体,然后按shift
选中平面。
那么圆锥体就是选中项
,然后平面就是活动项
。
圆锥体相对于平面这个活动体
Z轴对齐:
吸附相关:
shift+D
复制选中物体。ctrl+c + ctrl+v
也可以复制粘贴物体。
作为一款程序员或者建筑设计行业的一款建模软件
来讲,跟我们在学校里学的photoshop一样,需要投入主动学习成本,还有一些习惯上的成本比如一些快捷键取代图形化界面
是非常有必要的。
到最后再结合去做three.js
或者cesium
模型加载展示、材质处理和动画。
🔴 总结
当然,除了blender
,还有很多优秀的流行的3d
渲染软件:
blender
3dx Max
Maya
Cinema4D
KeyShot
一些室内设计师用的: cad
、酷家乐(要钱)
。
我们这篇讲的是blender
和threejs
的结合。就是说blender
负责建模和导出
,threejs
负责加载和交互
,去做出交互式3d网页应用
。
⭕︎ 流程
⭕︎ 1、blender导出:
在blender
中创建模型,然后导出格式为.glb
(二进制格式,包含材质、动画等)或.gltf
。
⭕︎ 2、加载模型并交互:
// 导出默认函数,用于创建城市场景
export default function createCity() {
// 创建GLTF加载器实例,用于加载.glb/.gltf格式的3D模型
const gltfLoader = new GLTFLoader();
// 加载城市模型文件
gltfLoader.load("./model/city.glb", (gltf) => {
// 遍历模型中的所有子对象
gltf.scene.traverse((item) => {
// 只处理网格类型(Mesh)的对象
if (item.type == "Mesh") {
console.log(item); // 调试用,打印网格信息
// 创建新的基础材质并设置为深蓝色
const cityMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(0x0c0e33), // 十六进制颜色值
});
item.material = cityMaterial; // 应用新材质到当前网格
// 调用自定义函数修改城市材质(函数实现未展示)
modifyCityMaterial(item);
// 特殊处理名为"Layerbuildings"的网格
if (item.name == "Layerbuildings") {
// 使用MeshLine库创建线框效果(需额外引入MeshLine库)
const meshLine = new MeshLine(item.geometry);
const size = item.scale.x; // 获取原始缩放值
meshLine.mesh.scale.set(size, size, size); // 保持原始比例
scene.add(meshLine.mesh); // 将线框添加到场景
}
}
});
// 将整个模型添加到场景中
scene.add(gltf.scene);
// 以下是被注释掉的可选效果,可根据需要取消注释:
// 添加普通飞线效果
const flyLine = new FlyLine();
scene.add(flyLine.mesh);
// 添加着色器实现的飞线(性能更好)
const flyLineShader = new FlyLineShader();
scene.add(flyLineShader.mesh);
// 添加雷达扫描效果
const lightRadar = new LightRadar();
scene.add(lightRadar.mesh);
// 添加光墙效果
const lightWall = new LightWall();
scene.add(lightWall.mesh);
// 添加可交互的警告标识
const alarmSprite = new AlarmSprite();
scene.add(alarmSprite.mesh);
// 绑定点击事件
alarmSprite.onClick(function (e) {
console.log("警告", e); // 点击时触发
});
});
}
来源:juejin.cn/post/7518932901699223592
前端佬们!塌房了!用过Element-Plus的进来~
原有的结论有问题,这是最新的弥补篇。如果您还没有看过,可以先看新篇提出的问题再看这个。如果您已看过,可以看下最新篇。希望不会给您带来困扰,随时接受大佬们的批评。
新篇戳这里。更崩溃!!感觉被偷家了!Element-plus组件测试的后续~
---------------------以下为原文---------------------------
进来着急的前端佬,我直接抛出结论吧!
Element-plus的组件,经过测验,如下组件存在内存泄漏。如下:
- el-carousel
- el-select + el-options
- el-descriptions
- el-tag
- el-dialog
- el-notification
- el-loading
- el-result
- el-message
- el-button
- el-tabs
- el-menu
- el-popper
验证环境为:
Vue Version: 3.5.13
Element Plus Version: 2.9.7
Browser / OS: window 10 / Edge 134.0.3124.85 (正式版本) (64 位)
Build Tool: Webpack
不排查ElementUI也存在这个问题。
好了。接下来细细聊。
前因
为什么检测到这种问题?主要因为一个项目引用了Element-plus。然后,你懂的,买的人永远都会想要最好的,然后买的人就这么一顿狂点Web页面,看见内存占用飙到老高。
于是...前端佬都懂的,来活了。
排查
一开始我是不敢怀疑这种高star开源组件的。总以为自己是写的代码有问题。
详细代码就不贴了,主要用ElDialog组件,封装成一个命令式的Dialog组件,避免频繁的使用v-modal参数。
然后,就直接怀疑上这个组件了。
经过测试,果不其然,从关闭到销毁,会导致内存猛增,因为Dialog中有各种表单组件,一打开就创建了一大堆的Element元素。
精确定位,使用了FinalizationRegistry类追踪创建的Dialog实体,代码如下:
const finalizerRegistry = new FinalizationRegistry((heldValue) => {
console.log('Finalizing instance: ',heldValue);
});
// 在创建处监听
const heldValue = Symbol(`DialogCommandComponent_${Date.now()}`);
finalizerRegistry.register(this, heldValue);
console.log(`Constructed instance:`,heldValue);
发现一直没有Constructed instance销毁的信息输出。
随后,使用了Edge浏览器中的分离元素来打快照,步骤如下图。
经过反复的操作,然后点击主动垃圾回收,然后发现el-dialog的元素都会增加,基本确认无疑了。
但还是怀疑,会不会是Dialog中,引用的问题,导致元素一直没能销毁?所以,使用了纯纯的el-dialog来校验,同样的操作,既然如故。
然后的然后,我使用了如下的代码,去校验其它组件是否存在同样的问题。代码如下:
<template>
<div>
<el-button @click="fn2">Reset</el-button>
</div>
<el-dialog v-model="model" destroy-on-close @closed="fn1" append-to-body v-if="destroyDialogModelValue"></el-dialog>
<el-button @click="fn0" v-if="!button" primse>Click</el-button>
<div class="weak" v-if="!button">xxx</div>
<el-input v-if="!button" />
<el-border v-if="!button" />
<el-select v-if="!button">
<el-option>1111</el-option>
</el-select>
<el-switch v-if="!button" />
<el-radio v-if="!button" />
<el-rate v-if="!button" />
<el-slider v-if="!button" />
<el-time-picker v-if="!button" />
<el-time-select v-if="!button" />
<el-transfer v-if="!button" />
<el-tree-select v-if="!button" />
<el-calendar v-if="!button" />
<el-card v-if="!button" />
<el-carousel height="150px" v-if="!button">
<el-carousel-item v-for="item in 4" :key="item">
<h3 class="small justify-center" text="2xl">{{ item }}</h3>
</el-carousel-item>
</el-carousel>
<el-descriptions title="User Info" v-if="!button">
<el-descriptions-item label="Username">kooriookami</el-descriptions-item>
</el-descriptions>
<el-table style="width: 100%" v-if="!button">
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" width="180" />
<el-table-column prop="address" label="Address" />
</el-table>
<el-avatar v-if="!button" />
<el-pagination layout="prev, pager, next" :total="50" v-if="!button" />
<el-progress :percentage="50" v-if="!button" />
<el-result icon="success" title="Success Tip" sub-title="Please follow the instructions" v-if="!button">
<template #extra>
<el-button type="primary">Back</el-button>
</template>
</el-result>
<el-skeleton v-if="!button" />
<el-tag v-if="!button" />
<el-timeline v-if="!button" />
<el-tree v-if="!button" />
<el-avatar v-if="!button" />
<el-segmented size="large" v-if="!button" />
<el-dropdown v-if="!button">
<span class="el-dropdown-link">
Dropdown List
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>Action 1</el-dropdown-item>
<el-dropdown-item>Action 2</el-dropdown-item>
<el-dropdown-item>Action 3</el-dropdown-item>
<el-dropdown-item disabled>Action 4</el-dropdown-item>
<el-dropdown-item divided>Action 5</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-menu class="el-menu-demo" mode="horizontal" v-if="!button">
<el-menu-item index="1">Processing Center</el-menu-item>
<el-sub-menu index="2">
<template #title>Workspace</template>
<el-menu-item index="2-1">item one</el-menu-item>
<el-menu-item index="2-2">item two</el-menu-item>
<el-menu-item index="2-3">item three</el-menu-item>
<el-sub-menu index="2-4">
<template #title>item four</template>
<el-menu-item index="2-4-1">item one</el-menu-item>
<el-menu-item index="2-4-2">item two</el-menu-item>
<el-menu-item index="2-4-3">item three</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<el-menu-item index="3" disabled>Info</el-menu-item>
<el-menu-item index="4">Orders</el-menu-item>
</el-menu>
<el-steps style="max-width: 600px" active="0" finish-status="success" v-if="!button">
<el-step title="Step 1" />
<el-step title="Step 2" />
<el-step title="Step 3" />
</el-steps>
<el-tabs class="demo-tabs" v-if="!button">
<el-tab-pane label="User" name="first">User</el-tab-pane>
<el-tab-pane label="Config" name="second">Config</el-tab-pane>
<el-tab-pane label="Role" name="third">Role</el-tab-pane>
<el-tab-pane label="Task" name="fourth">Task</el-tab-pane>
</el-tabs>
<el-alert title="Success alert" type="success" v-if="!button" />
<el-drawer title="I am the title" v-if="!button">
<span>Hi, there!</span>
</el-drawer>
<div v-loading="model" v-if="!button"></div>
<el-popconfirm confirm-button-text="Yes" cancel-button-text="No" icon-color="#626AEF"
title="Are you sure to delete this?" v-if="!button">
<template #reference>
<el-button>Delete</el-button>
</template>
</el-popconfirm>
<el-popover class="box-item" title="Title" content="Top Center prompts info" placement="top" v-if="!button">
<template #reference>
<div>top</div>
</template>
</el-popover>
<el-tooltip class="box-item" effect="dark" content="Top Left prompts info" placement="top-start" v-if="!button">
<div>top-start</div>
</el-tooltip>
</template>
<script setup>
import { ref } from "vue";
import { ElMessage, ElMessageBox, ElNotification } from "element-plus";
const model = ref(false);
const destroyDialogModelValue = ref(false);
const button = ref(false);
function fn0() {
model.value = true;
destroyDialogModelValue.value = true;
ElMessage("This is a message.");
ElMessageBox.alert("This is a message", "Title");
ElNotification({
title: "Title",
message: "This is a reminder",
});
}
function fn1() {
console.log("closed");
destroyDialogModelValue.value = false;
button.value = true;
}
function reset() {
model.value = false
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
如上代码,进入页面后,点击click,然后关闭所有的弹窗。然后再次点击reset按钮,然后再次点击click,关闭所有弹窗。如此可以多操作几次。
就发现了开头的组件,都存在内存泄漏问题。
未能解决
有问题,当然首先看看别人有没有出现过。各种搜索就不说了,大掘金也搜过,在Element-plus的github仓里的Issues中找过,发现的办法基本无用。
以下是自己思考的几条路子:
- 有泄漏的,都手搓一个?
- Eldialog全局只用一到两个?
- 将所有路由,都打成一个单页面(html)。
- 改源码....
结尾
还是在这里,求助大佬,看以上思路是否有错,然后跪求orz解决办法。
自己后续如果解决对应一些问题,会即时和大家分享。
来源:juejin.cn/post/7485966905418760227
Vue实现一个“液态玻璃”效果登录卡片
Vue实现一个“液态玻璃”效果登录卡片
效果介绍
液态玻璃(Liquid Glass)是一种极具现代感的UI视觉风格,常见于高端网站和操作系统界面。它通过多层叠加、模糊、光泽、滤镜等技术,模拟出玻璃的通透、折射和高光质感。苹果的这次系统设计更新,带火了这一设计效果,本教程将带你一步步实现一个带有3D灵动倾斜交互的液态玻璃登录卡片。
实际效果:
技术原理解析
1. 多层叠加
液态玻璃效果的核心是多层视觉叠加:
- 模糊层(blur):让背景内容变得虚化,产生玻璃的通透感。
- 色调层(tint):为玻璃加上一层淡淡的色彩,提升质感。
- 高光层(shine):模拟玻璃边缘的高光和内阴影,增强立体感。
- SVG滤镜:通过 SVG 的
feTurbulence
和feDisplacementMap
,让玻璃表面产生微妙的扭曲和流动感。
2. 3D灵动倾斜
通过监听鼠标在卡片上的移动,动态计算并设置 transform: perspective(...) rotateX(...) rotateY(...)
,让卡片随鼠标灵动倾斜,增强交互体验。
3. 背景与环境
背景可以是渐变色,也可以是图片。玻璃卡片通过 backdrop-filter
与背景内容产生交互,形成真实的玻璃质感。
实现步骤详解
1. 结构搭建
<template>
<div class="login-container animated-background">
<!-- SVG滤镜库 -->
<svg style="display: none">...</svg>
<!-- 登录卡片 -->
<div
class="glass-component login-card"
ref="tiltCard"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="glass-effect"></div>
<div class="glass-tint"></div>
<div class="glass-shine"></div>
<div class="glass-content">
<!-- 登录表单内容 -->
</div>
</div>
</div>
</template>
2. SVG滤镜实现液态扭曲
<svg style="display: none">
<filter id="glass-distortion" x="0%" y="0%" width="100%" height="100%" filterUnits="objectBoundingBox">
<feTurbulence type="fractalNoise" baseFrequency="0.001 0.005" numOctaves="1" seed="17" result="turbulence" />
<feComponentTransfer in="turbulence" result="mapped">
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
</feComponentTransfer>
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
<feSpecularLighting in="softMap" surfaceScale="5" specularConstant="1" specularExponent="100" lighting-color="white" result="specLight">
<fePointLight x="-200" y="-200" z="300" />
</feSpecularLighting>
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="200" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
- 这段 SVG 代码必须放在页面结构内,供 CSS filter 调用。
3. 背景设置
.animated-background {
width: 100vw;
height: 100vh;
background-image: url('你的背景图片路径');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: fixed;
top: 0;
left: 0;
z-index: -1;
}
- 建议用高质量渐变或壁纸,能更好衬托玻璃质感。
4. 卡片多层玻璃结构
.login-card {
width: 400px;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.10), 0 1.5px 6px 0 rgba(0,0,0,0.08);
background: transparent;
position: relative;
}
.glass-effect {
position: absolute;
inset: 0;
z-index: 0;
backdrop-filter: blur(5px);
filter: url(#glass-distortion);
isolation: isolate;
border-radius: 24px;
}
.glass-tint {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.15);
border-radius: 24px;
}
.glass-shine {
position: absolute;
inset: 0;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 24px;
box-shadow:
inset 1px 1px 8px 0 rgba(255, 255, 255, 0.18),
inset -1px -1px 8px 0 rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.glass-content {
position: relative;
z-index: 3;
padding: 2rem;
color: white;
}
- 每一层都要有一致的 border-radius,才能保证圆角处无割裂。
5. 3D灵动倾斜交互
methods: {
handleMouseMove (e) {
const card = this.$refs.tiltCard
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
const maxTilt = 18
const rotateY = ((x - centerX) / centerX) * maxTilt
const rotateX = -((y - centerY) / centerY) * maxTilt
card.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.03)`
},
handleMouseLeave () {
const card = this.$refs.tiltCard
card.style.transform = 'perspective(600px) rotateX(0deg) rotateY(0deg) scale(1)'
}
}
- 鼠标移动时,卡片会根据指针位置灵动倾斜。
- 鼠标移出时,卡片平滑恢复。
6. 细节优化
- 阴影柔和:避免黑色边缘过重,提升高级感。
- 高光线条:用低透明度白色边框和内阴影,模拟玻璃高光。
- 所有层的圆角一致:防止割裂。
- 表单输入框:用半透明背景和模糊,保持整体风格统一。
7.完整代码
<template>
<div class="login-container animated-background">
<!-- SVG滤镜库 -->
<svg style="display: none">
<filter id="glass-distortion" x="0%" y="0%" width="100%" height="100%" filterUnits="objectBoundingBox">
<feTurbulence type="fractalNoise" baseFrequency="0.001 0.005" numOctaves="1" seed="17" result="turbulence" />
<feComponentTransfer in="turbulence" result="mapped">
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
</feComponentTransfer>
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
<feSpecularLighting in="softMap" surfaceScale="5" specularConstant="1" specularExponent="100" lighting-color="white" result="specLight">
<fePointLight x="-200" y="-200" z="300" />
</feSpecularLighting>
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="200" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
<!-- 登录卡片 -->
<div
class="glass-component login-card"
ref="tiltCard"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<div class="glass-effect"></div>
<div class="glass-tint"></div>
<div class="glass-shine"></div>
<div class="glass-content">
<h2 class="login-title">欢迎登录</h2>
<form class="login-form">
<div class="form-group">
<input type="text" placeholder="用户名" class="glass-input">
</div>
<div class="form-group">
<input type="password" placeholder="密码" class="glass-input">
</div>
<button type="submit" class="glass-button">登录</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'LiquidGlass',
data () {
return {
// 可以添加需要的数据
}
},
methods: {
handleMouseMove (e) {
const card = this.$refs.tiltCard
const rect = card.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
// 最大旋转角度
const maxTilt = 18
const rotateY = ((x - centerX) / centerX) * maxTilt
const rotateX = -((y - centerY) / centerY) * maxTilt
card.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.03)`
},
handleMouseLeave () {
const card = this.$refs.tiltCard
card.style.transform = 'perspective(600px) rotateX(0deg) rotateY(0deg) scale(1)'
}
}
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.animated-background {
width: 100%;
height: 100%;
background-image: url('../../assets/macwallpaper.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.login-card {
width: 400px;
position: relative;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.10), 0 1.5px 6px 0 rgba(0,0,0,0.08);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.6);
cursor: pointer;
background: transparent;
}
.glass-effect {
position: absolute;
inset: 0;
z-index: 0;
backdrop-filter: blur(5px);
filter: url(#glass-distortion);
isolation: isolate;
border-radius: 24px;
}
.glass-tint {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.15);
border-radius: 24px;
}
.glass-shine {
position: absolute;
inset: 0;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 24px;
box-shadow:
inset 1px 1px 8px 0 rgba(255, 255, 255, 0.18),
inset -1px -1px 8px 0 rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.glass-content {
position: relative;
z-index: 3;
padding: 2rem;
color: white;
}
.login-title {
text-align: center;
color: #fff;
margin-bottom: 2rem;
font-size: 2rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.form-group {
margin-bottom: 1.5rem;
}
.glass-input {
width: 90%;
padding: 12px 20px;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 1rem;
backdrop-filter: blur(5px);
transition: all 0.3s ease;
&::placeholder {
color: rgba(255, 255, 255, 0.7);
}
&:focus {
outline: none;
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
}
}
.glass-button {
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
position: relative;
overflow: hidden;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
}
// 添加点击波纹效果
.click-gradient {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.4) 0%, rgba(180,180,255,0.2) 40%, rgba(100,100,255,0.1) 70%, rgba(50,50,255,0) 100%);
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
z-index: 4;
}
.glass-component.clicked .click-gradient {
animation: gradient-ripple 0.6s ease-out;
}
@keyframes gradient-ripple {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(3);
opacity: 0;
}
}
.glass-component {
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
</style>
常见问题与优化建议
- 阴影过重/黑边:减小 box-shadow 的透明度和模糊半径。
- 圆角割裂:所有玻璃层都要加 border-radius。
- 背景不通透:确保 glass-effect 层有 blur 和 SVG filter。
- 性能问题:backdrop-filter 在低端设备上可能有性能损耗,建议只在必要区域使用。
- 浏览器兼容性:backdrop-filter 需现代浏览器支持,IE/部分安卓浏览器不兼容。
技术要点总结
- SVG滤镜:让玻璃表面有微妙的流动和扭曲感。
- backdrop-filter: blur:实现背景虚化。
- 多层叠加:色调、高光、阴影共同营造真实玻璃质感。
- 3D transform:提升交互体验。
- 细节打磨:阴影、边框、圆角、色彩都要精细调整。
结语
液态玻璃效果是现代前端视觉的代表之一。只要理解其原理,分层实现、细致调优,任何人都能做出媲美 macOS、Win11 的高端玻璃UI。希望本教程能帮助你掌握这项技术,做出属于自己的酷炫界面!
来源:juejin.cn/post/7516306850715910182
尤雨溪搞响应式为什么要从 Object.defineProperty 换成 Proxy❓
前言
你说,为什么❓尤雨溪搞响应式,他为什么要换掉Object.defineProperty呢❓
proxy什么来头❓
有一次👀看他直播,说去面试人家问他
原型链
,他不会,GG了面试黄了,你说他是不是无中生有暗度陈仓凭空想象凭空捏造new Proxy
来换掉Object.defineProperty
的呢?
还真不是,尤雨溪的响应式,我们暂且叫成插一脚
吧👇,请听我细细道来👂
在前端开发中,响应式系统是现代框架的核心特性。无论是 Vue 还是 React,它们都需要实现一个基本功能:当数据变化时,自动更新相关的视图。用通俗的话说,就是要在数据被读取或修改时"插一脚",去执行一些额外的操作(比如界面刷新、计算属性重新计算等)。
// 读取属性时
obj.a; // 需要知道这个属性被读取了
// 修改属性时
obj.a = 3; // 需要知道这个属性被修改了
但原生 JavaScript 对象不会告诉我们这些操作的发生。那么,尤雨溪是如何实现这种"插一脚"的能力的呢?
正文
Vue 2 的"插一脚"方案 - Object.defineProperty
基本实现原理
Vue 2 使用的是 ES5 的 Object.defineProperty
API。这个 API 允许我们定义或修改对象的属性,并为其添加 getter 和 setter。
const obj = { a: 1 };
let v = obj.a;
Object.defineProperty(obj, 'a', {
get() {
console.log('读取 a'); // 插一脚:知道属性被读取了
return v;
},
set(val) {
console.log('更新 a'); // 插一脚:知道属性被修改了
v = val;
}
});
obj.a; // 输出"读取 a"
obj.a = 3; // 输出"更新 a"
完整对象监听
为了让整个对象可响应,Vue 2 需要遍历对象的所有属性:
function observe(obj) {
for (const k in obj) {
let v = obj[k];
Object.defineProperty(obj, k, {
get() {
console.log('读取', k);
return v;
},
set(val) {
console.log('更新', k);
v = val;
}
});
}
}
处理嵌套对象
对于嵌套对象,还需要递归地进行观察:
function _isObject(v) {
return typeof v === 'object' && v !== null;
}
function observe(obj) {
for (const k in obj) {
let v = obj[k];
if (_isObject(v)) {
observe(v); // 递归处理嵌套对象
}
Object.defineProperty(obj, k, {
get() {
console.log('读取', k);
return v;
},
set(val) {
console.log('更新', k);
v = val;
}
});
}
}
Vue 2 方案的两大缺陷
缺陷一:效率问题
在这种模式下,他就必须要去遍历这个对象里边的每一个属性...这是第一个缺陷:必须遍历对象的所有属性,对于大型对象或深层嵌套对象,这会带来性能开销。
缺陷二:新增属性问题
无法检测到对象属性的添加或删除:
obj.d = 2; // 这个操作不会被监听到
因为一开始遍历的时候没有这个属性,后续添加的属性不会被自动观察。
Vue 3 的"插一脚"方案 - Proxy
基本实现原理
Vue 3 使用 ES6 的 Proxy
来重构响应式系统。Proxy 可以拦截整个对象的操作,而不是单个属性。
const obj = { a: 1 };
const proxy = new Proxy(obj, {
get(target, k) {
console.log('读取', k); // 插一脚
return target[k];
},
set(target, k, val) {
if (target[k] === val) return true;
console.log('更新', k); // 插一脚
target[k] = val;
return true;
}
});
proxy.a; // 输出"读取 a"
proxy.a = 3; // 输出"更新 a"
proxy.d; // 输出"读取 d" - 连不存在的属性也能监听到!
完整实现
function _isObject(v) {
return typeof v === 'object' && v !== null;
}
function reactive(obj) {
const proxy = new Proxy(obj, {
get(target, k) {
console.log('读取', k);
const v = target[k];
if (_isObject(v)) {
return reactive(v); // 惰性递归
}
return v;
},
set(target, k, val) {
if (target[k] === val) return true;
console.log('更新', k);
target[k] = val;
return true;
}
});
return proxy;
}
Proxy 的优势
- 无需初始化遍历:直接代理整个对象,不需要初始化时遍历所有属性
- 全面拦截:可以检测到所有属性的访问和修改,包括新增属性
- 性能更好:采用惰性处理,只在属性被访问时才进行响应式处理
- 更自然的开发体验:不需要特殊 API 处理数组和新增属性
"proxy 它解决了什么问题?两个问题。
第一个问题不需要深度遍历了,因为它不再监听属性了,而是监听的什么?整个对象。
同时也由于它监听了整个对象,就解决了第二个问题:能监听这个对象的所有操作,包括你去读写一些不存在的属性,都能监听到。"
原理对比与源码解析
原理对比
特性 | Object.defineProperty | Proxy |
---|---|---|
拦截方式 | 属性级别 | 对象级别 |
新增属性检测 | 不支持 | 支持 |
性能 | 初始化时需要遍历 | 按需处理 |
深层嵌套处理 | 初始化时递归处理 | 访问时递归处理 |
源码实现差异
Vue 2 实现:
- 在
src/core/observer
目录下 - 初始化时递归遍历整个对象
- 需要特殊处理数组方法
Vue 3 实现:
- 独立的
@vue/reactivity
包 - 使用 Proxy 实现基础响应式
- 惰性处理嵌套对象
- 更简洁的 API 设计
为什么 Proxy 是更好的选择?
- 更全面的拦截能力:可以拦截对象的所有操作,包括属性访问、赋值、删除等
- 更好的性能:不需要初始化时递归遍历整个对象
- 更简洁的 API:不再需要 Vue.set/Vue.delete 等特殊 API
- 更自然的开发体验:开发者可以使用普通的 JavaScript 语法操作对象
总结
需显式操作(defineProperty)-> 声明式编程(Proxy)
局部监听(属性级别)-> 全局拦截(对象级别)
。
从 Object.defineProperty 到 Proxy 的转变,不仅是 API 的升级,更是前端框架设计理念的进步。Vue 3 的响应式系统通过 Proxy 实现了更高效、更全面的数据监听。
来源:juejin.cn/post/7493539513106677769
状态机设计:比if-else优雅100倍的设计
作为一名后端开发工程师,当你面对复杂的业务流程时,是否常感到逻辑混乱、边界不清?学会状态机设计,让你的代码优雅如诗!
引言:为什么需要状态机?
在后台系统开发中,我们经常需要处理对象的状态流转问题:订单从"待支付"到"已支付"再到"已发货",工单系统从"打开"到"处理中"再到"解决",这些场景都涉及状态管理。
如果不使用状态机设计,我们可能会写出这样的面条式代码:
func HandleOrderEvent(order *Order, event Event) error {
if order.Status == "待支付" {
if event.Type == "支付成功" {
order.Status = "已支付"
// 执行支付成功逻辑...
} else if event.Type == "取消订单" {
order.Status = "已取消"
// 执行取消逻辑...
} else {
return errors.New("非法事件")
}
} else if order.Status == "已支付" {
if event.Type == "发货" {
order.Status = "已发货"
// 执行发货逻辑...
}
// 更多else if...
}
// 更多else if...
}
这种代码存在几个致命问题:
- 逻辑分支嵌套严重(俗称箭头代码)
- 状态流转规则难以维护
- 容易遗漏边界条件
- 可扩展性差(新增状态需要改动核心逻辑)
状态机正是解决这类问题的银弹!
状态机设计核心概念
状态机三要素
概念 | 描述 | 订单系统示例 |
---|---|---|
状态(State) | 系统所处的稳定状态 | 待支付、已支付、已发货 |
事件(Event) | 触发状态变化的动作 | 支付成功、取消订单 |
转移(Transition) | 状态变化的规则 | 待支付 → 已支付 |
状态机的类型
- 有限状态机(FSM):最简单的状态机形式
- 分层状态机(HSM):支持状态继承,减少冗余
- 状态图(Statecharts):支持并发、历史状态等高级特性
graph LR
A[待支付] -->|支付成功| B[已支付]
B -->|发货| C[已发货]
B -->|申请退款| D[退款中]
A -->|取消订单| E[已取消]
D -->|退款成功| E
D -->|退款失败| B
Go实现状态机实战
基本结构定义
package main
import "fmt"
// 定义状态类型
type State string
// 定义事件类型
type Event string
// 状态转移函数类型
type TransitionHandler func() error
// 状态转移定义
type Transition struct {
From State
Event Event
To State
Handle TransitionHandler
}
// 状态机定义
type StateMachine struct {
Current State
transitions []Transition
}
// 注册状态转移规则
func (sm *StateMachine) AddTransition(from State, event Event, to State, handler TransitionHandler) {
sm.transitions = append(sm.transitions, Transition{
From: from,
Event: event,
To: to,
Handle: handler,
})
}
// 处理事件
func (sm *StateMachine) Trigger(event Event) error {
for _, trans := range sm.transitions {
if trans.From == sm.Current && trans.Event == event {
// 执行处理函数
if err := trans.Handle(); err != nil {
return err
}
// 更新状态
sm.Current = trans.To
return nil
}
}
return fmt.Errorf("非法事件[%s]或当前状态[%s]不支持", event, sm.Current)
}
订单状态机示例
// 订单状态定义
const (
StatePending State = "待支付"
StatePaid State = "已支付"
StateShipped State = "已发货"
StateCanceled State = "已取消"
)
// 事件定义
const (
EventPaySuccess Event = "支付成功"
EventCancel Event = "取消订单"
EventShip Event = "发货"
)
func main() {
// 创建状态机
sm := &StateMachine{Current: StatePending}
// 注册状态转移
sm.AddTransition(StatePending, EventPaySuccess, StatePaid, func() error {
fmt.Println("执行支付成功处理逻辑...")
return nil // 实际业务中可能有错误处理
})
sm.AddTransition(StatePending, EventCancel, StateCanceled, func() error {
fmt.Println("执行订单取消逻辑...")
return nil
})
sm.AddTransition(StatePaid, EventShip, StateShipped, func() error {
fmt.Println("执行发货逻辑...")
return nil
})
sm.AddTransition(StatePaid, EventCancel, StateCanceled, func() error {
fmt.Println("执行已支付状态的取消逻辑...")
return nil
})
// 执行事件测试
fmt.Println("当前状态:", sm.Current)
_ = sm.Trigger(EventPaySuccess) // 支付成功
fmt.Println("当前状态:", sm.Current)
_ = sm.Trigger(EventShip) // 发货
fmt.Println("当前状态:", sm.Current)
// 测试非法转移
err := sm.Trigger(EventCancel)
fmt.Println("尝试取消:", err) // 非法操作
}
输出结果:
当前状态: 待支付
执行支付成功处理逻辑...
当前状态: 已支付
执行发货逻辑...
当前状态: 已发货
尝试取消: 非法事件[取消订单]或当前状态[已发货]不支持
扩展:表驱动状态机
上面的实现足够清晰,但存在性能问题——每次触发事件都需要遍历转移表。我们优化为更高效的版本:
type StateMachineV2 struct {
Current State
transitionMap map[State]map[Event]*Transition
}
func (sm *StateMachineV2) AddTransition(from State, event Event, to State, handler TransitionHandler) {
if sm.transitionMap == nil {
sm.transitionMap = make(map[State]map[Event]*Transition)
}
if _, exists := sm.transitionMap[from]; !exists {
sm.transitionMap[from] = make(map[Event]*Transition)
}
sm.transitionMap[from][event] = &Transition{
From: from,
Event: event,
To: to,
Handle: handler,
}
}
func (sm *StateMachineV2) Trigger(event Event) error {
if events, exists := sm.transitionMap[sm.Current]; exists {
if trans, exists := events[event]; exists {
if err := trans.Handle(); err != nil {
return err
}
sm.Current = trans.To
return nil
}
}
return fmt.Errorf("非法事件[%s]或当前状态[%s]不支持", event, sm.Current)
}
进阶技巧:状态机实践指南
状态转移图可视化
绘制状态转移图,与代码实现保持同步:
状态模式的优雅实现
使用Go的接口特性实现面向对象的状态模式:
type OrderState interface {
Pay() error
Cancel() error
Ship() error
// 其他操作方法...
}
type pendingState struct{}
func (s *pendingState) Pay() error {
fmt.Println("执行支付成功处理逻辑...")
return nil
}
func (s *pendingState) Cancel() error {
fmt.Println("执行待支付状态取消逻辑...")
return nil
}
func (s *pendingState) Ship() error {
return errors.New("当前状态不能发货")
}
// 其他状态实现...
type Order struct {
state OrderState
}
func (o *Order) ChangeState(state OrderState) {
o.state = state
}
func (o *Order) Pay() error {
return o.state.Pay()
}
// 其他方法...
状态机的持久化
如何在数据库中存储状态机?永远只存储状态,而不是存储状态机逻辑!
数据库表设计示例:
字段名 | 类型 | 描述 |
---|---|---|
id | int | 主键ID |
status | varchar(20) | 当前状态 |
event_history | json | 事件历史记录 |
状态恢复代码实现:
type Order struct {
ID int
Status State
}
func RecoverOrderStateMachine(order Order) *StateMachine {
sm := CreateStateMachine() // 创建初始状态机
sm.Current = order.Status // 恢复状态
return sm
}
真实案例:电商订单系统
复杂状态机设计
处理并发操作
var mutex sync.Mutex
func (sm *StateMachine) SafeTrigger(event Event) error {
mutex.Lock()
defer mutex.Unlock()
return sm.Trigger(event)
}
// 使用channel同步
func (sm *StateMachine) AsyncTrigger(event Event) error {
eventChan := make(chan error)
go func() {
mutex.Lock()
defer mutex.Unlock()
eventChan <- sm.Trigger(event)
}()
return <-eventChan
}
避免状态机设计的反模式
- 过度复杂的状态机:如果状态超过15个,考虑拆分
- 上帝状态机:避免一个状态机控制整个系统
- 忽略状态回退:重要系统必须设计回退机制
- 缺乏监控:记录状态转移日志
监控状态转移示例:
func (sm *StateMachine) Trigger(event Event) error {
startTime := time.Now()
defer func() {
log.Printf("状态转移监控: %s->%s (%s) 耗时: %v",
oldState, sm.Current, event, time.Since(startTime))
}()
// 正常处理逻辑...
}
结语:状态机的无限可能
状态机不只是解决业务逻辑的工具,它更是一种思维方式。通过今天的学习,你应该掌握了:
- 状态机的基本概念与类型 ✅
- Go语言实现状态机的多种方式 ✅
- 复杂状态机的设计技巧 ✅
- 真实项目的状态机应用模式 ✅
当你在设计下一个后端系统时,先问自己三个问题:
- 我的对象有哪些明确的状态?
- 触发状态变化的事件是什么?
- 状态转移需要哪些特殊处理?
思考清楚这些问题,你的代码设计将变得更加清晰优雅!
来源:juejin.cn/post/7513752860162129960
用了三年 Vue,我终于理解为什么“组件设计”才是重灾区
一开始写 Vue 的时候,谁不是觉得:“哇,组件好优雅!”三年后再回头一看,组件目录像垃圾堆,维护一处改三处,props 乱飞、事件满天飞,复用全靠 copy paste。于是我终于明白 —— 组件设计,才是 Vue 项目的重灾区。
1. 抽组件 ≠ 拆文件夹
很多初学 Vue 的人对“组件化”的理解就是:“页面上出现重复的 UI?好,抽个组件。”
于是你会看到这样的组件:
<!-- TextInput.vue -->
<template>
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</template>
接着你又遇到需要加图标的输入框,于是复制一份:
<!-- IconTextInput.vue -->
<template>
<div class="icon-text-input">
<i class="icon" :class="icon" />
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</div>
</template>
再后来你需要加验证、loading、tooltip……结果就变成了:
TextInput.vue
IconTextInput.vue
ValidatableInput.vue
LoadingInput.vue
FormInput.vue
组件爆炸式增长,但每一个都只是“刚好凑合”,共用不了。
2. 抽象失控:为了复用而复用,结果没人敢用
比如下面这个场景:
你封装了一个超级复杂的表格组件:
<CustomTable
:columns="columns"
:data="tableData"
:show-expand="true"
:enable-pagination="true"
:custom-actions="['edit', 'delete']"
/>
你美其名曰“通用组件”,但别人拿去一用就发现:
- 某个页面只要展示,不要操作按钮,配置了也没法删;
- 有个页面需要自定义排序逻辑,你这边死写死;
- 另一个页面用 element-plus 的样式,这边你自绘一套 UI;
- 报错时控制台输出一大堆 warning,根本不知道哪来的。
最后大家的做法就是 —— 不用你这套“通用组件”,自己抄一份改改。
3. 数据向下流、事件向上传:你真的理解 props 和 emit 吗?
Vue 的单向数据流原则说得很清楚:
父组件通过 props 向下传数据,子组件通过 emit 通知父组件。
但现实是:
- props 传了 7 层,页面逻辑根本看不懂数据哪来的;
- 子组件 emit 了两个 event,父组件又传回了回调函数;
- 有时候干脆直接用
inject/provide
、ref
、eventBus
偷偷打通通信。
举个例子:
<!-- 祖父组件 -->
<template>
<PageWrapper>
<ChildComponent :formData="form" @submit="handleSubmit" />
</PageWrapper>
</template>
<!-- 子组件 -->
<template>
<Form :model="formData" />
<button @click="$emit('submit', formData)">提交</button>
</template>
看上去还好?但当 ChildComponent
再包一层 FormWrapper
、再嵌套 InputList
,你就发现:
formData
根本不知道是哪个组件控制的submit
被多层包装、debounce、防抖、节流、劫持- 你改一个按钮逻辑,要翻 4 个文件
4. 技术债爆炸的罪魁祸首:不敢删、不敢动
组件目录看似整齐,但大部分组件都有如下特征:
- 有 10 个 props,3 个事件,但没人知道谁在用;
- 注释写着“用于 A 页面”,实际上 B、C、D 页面也在引用;
- 一个小改动能引发“蝴蝶效应”,整个系统发疯。
于是你只能选择 —— 拷贝再新建一个组件,给它加个 V2
后缀,然后老的你也不敢删。
项目后期的结构大概就是:
components/
├── Input.vue
├── InputV2.vue
├── InputWithTooltip.vue
├── InputWithValidation.vue
├── InputWithValidationV2.vue
└── ...
“为了让别人能维护我的代码,我决定不动它。”
5. 组件设计的核心,其实是抽象能力
我用三年才悟到一个道理:
Vue 组件设计的难点,不是语法、也不是封装,而是你有没有抽象问题的能力。
举个例子:
你需要设计一个“搜索区域”组件,包含输入框 + 日期范围 + 搜索按钮。
新手写法:
<SearchHeader
:keyword="keyword"
:startDate="start"
:endDate="end"
@search="handleSearch"
/>
页面需求一改,换成了下拉框 + 单选框怎么办?又封一个组件?
更好的设计是 —— 提供slots 插槽 + 作用域插槽:
<!-- SearchHeader.vue -->
<template>
<div class="search-header">
<slot name="form" />
<button @click="$emit('search')">搜索</button>
</div>
</template>
<!-- 使用 -->
<SearchHeader @search="search">
<template #form>
<el-input v-model="keyword" placeholder="请输入关键词" />
<el-date-picker v-model="range" type="daterange" />
</template>
</SearchHeader>
把结构交给组件,把行为交给页面。组件不掌控一切,而是协作。
6. 那么组件怎么设计才对?
我总结出 3 条简单但有效的建议:
✅ 1. 明确组件职责:UI?交互?逻辑?
- UI 组件只关心展示,比如按钮、标签、卡片;
- 交互组件只封装用户操作,比如输入框、选择器;
- 逻辑组件封装业务规则,比如筛选区、分页器。
别让一个组件又画 UI 又写逻辑还请求接口。
✅ 2. 精简 props 和 emit,只暴露“必需”的接口
- 一个组件 props 超过 6 个,要小心;
- 如果事件名不具备业务语义(比如
click
),考虑抽象; - 不要用
ref
操作子组件的内部逻辑,那是反模式。
✅ 3. 使用 slots 替代“高度定制的 props 方案”
如果你发现你组件 props 变成这样:
<SuperButton
:label="'提交'"
:icon="'plus'"
:iconPosition="'left'"
:styleType="'primary'"
:loading="true"
/>
那它该用 slot 了:
<SuperButton>
<template #icon><PlusIcon /></template>
提交
</SuperButton>
🙂
三年前我以为组件化是 Vue 最简单的部分,三年后我才意识到,它是最深、最难、最容易出坑的部分。
如果你也踩过以下这些坑:
- 组件复用越写越复杂,别人都不敢用;
- props 和事件像迷宫一样,维护成本极高;
- UI 和逻辑耦合,改一点动全身;
- 项目后期组件膨胀、技术债堆积如山;
别再让组件成为项目的“技术债”。你们也有遇到吗?
📌 你可以继续看我的系列文章
来源:juejin.cn/post/7514947261396205587
Vite 底层彻底换血,尤雨溪想要控制整个前端生态?
Hello,大家好,我是 Sunday。
最近,尤雨溪发了一篇非常关键的文章,宣布 Vite 正式引入 Rust 写的打包器 Rolldown,并将逐步替代现有的 Rollup 成为 默认打包器。
该文章发布在 尤雨溪 新公司
void(0)
,文章链接:https://voidzero.dev/posts/announcing-rolldown-vite
虽然这篇文章的内容并不长,但是内部做出的改成确实非常大的,可以毫不夸张的说:尤雨溪把整个 vite
的心脏都换掉了!
所以,咱们今天这篇文章,我不打算重复发布会上的内容,而是一起来看看这波 “换心脏” 的背后逻辑:为什么 Rust 能上位?真实速度到底快了多少?尤雨溪到底在下一盘什么棋?
01:Vite 正在全面 Rust 化
很多人看到这次更新,可能会说:“Rolldown 不就是个性能更好的打包器吗?用不用都行吧?”
说实话,这种理解可能有些过于表面了。
这次更新的不仅仅是一个工具,而是把整个的 vite 底层都重写了一遍:
- Vite 的打包器,从 JS 写的 Rollup,换成了 Rust 写的 Rolldown
- 配套的 Babel、Terser、ESLint,也被 Rust 实现的 Oxc 接管
- 整个构建链路,从解析到压缩,从转换到分析,全都 Rust
为什么要这么干呢?
很简单,因为:JS 写的构建工具已经摸到天花板了。
不管你怎么做缓存、怎么压缩 AST、怎么优化 Plugin 顺序,JS 就是做不到 Rust 那种级别的执行效率。
而现代前端的项目体积正在变得越来越大,早就不是之前只写几个静态页面的事情了!
目前 微前端、组件库、国际化、权限系统……每加一个功能,构建时间就会变得越来越长,特别是很多公司在配合 CI/CD
的逻辑,每构建跑一次可能就得跑 2 分钟,而如果换成 Rolldown 那么就只要 15 秒上下了,你算算整个团队每天省下多少时间?
因此,这样的替换 “势在必行”,同时这也标记着:Vite 已经不再是一个 JS 写的现代前端工具了,而是一个由 Rust 驱动的、高性能构建内核。
02:真实表现到底快了多少?
咱们先来看官方数据:
这些官方给出的数据看上去是不是非常炸裂!
但是,根据前端圈的历史特性:所有官方性能对比,都只能作为参考,而不能作为实际的决策依据。
为啥呢?
因为,实际开发中,环境不同、项目结构不同、依赖链不同、构建目标不同,变量太多了。
很多的 demo 是干净环境下跑的,而你实际项目里,插件、polyfill、非预构建依赖一大堆,所以 官方数据,仅供参考!
但我要说的是:哪怕实际操作中,只能做到官方数据的一半,这件事也值得我们去尝试下。
就拿我自己接触的几个中大型项目来说,生产环境下的 Vite 构建时间基本都在 30 秒到 2 分钟之间浮动,特别是:
- 多语言、主题、子应用拆包场景下,Rollup 明显吃力
- babel + terser 的组合在压缩阶段特别耗 CPU
- 内存比较小的,如果在启动其他的任务(你电脑总得开其他的软件吧),那速度就更慢了
换句话说,如果 Rolldown 真能在这些环节上带来 哪怕 30% 的性能提升,对于团队的持续集成、构建稳定性、开发反馈体验,都是实打实的收益。
03:尤雨溪在下一盘大棋
很多同学可能会说:Vite 已经“遥遥领先”了,为啥还非要换底层呢?多麻烦呀!
如果你有这种疑惑的话,那么可能是因为你对 vite
使用到的这些新工具还不太了解,如果你了解一下背后的发布方,就知道这件事没那么简单。
Rolldown 是谁发布的?不是 Vue,也不是 Vite 核心团队,而是尤雨溪创办的新公司 —— VoidZero (也叫做 void(0) ) 。想要详细了解的,可以看下我之前发的这篇文章 尤雨溪新公司 Void(0) 首款产品发布,竟然是它...
这是一家由 尤雨溪 创建的专门做 JavaScript 工具链的开源公司。关于这一块的详细介绍,可以看这篇博客 尤雨溪创建 VoidZero ,并得到 460 万美金融资
这家公司刚一出手就连放两个大招:
- 第一个是 Oxc :这是一个全新的 Rust 实现 JS 工具链(parser、transform、minifier、linter、formatter,全都自己造)
- 第二个就是 Rolldown:Vite 打包器的 Rust 替代方案,目标直接瞄准 Rollup、Babel、Terser 这整条传统链路
而这次 Vite 接入 Rolldown,正是 void(0) 把自家工具「回注入」开源生态的第一步。
所以这不是在“优化 Vite”,而是想要 “替换整条构建基础设施”
你可以这么理解 void(0) 的策略路径:
- Vue 站稳前端框架圈核心位置
- Vite 用 Rollup 起家,成为构建工具主流选择
- void(0) 作为新公司登场,切入工具链底层,用 Rust 重写一整套生态
- 再反哺 Vite,用 Rolldown 替代原来的 JS 构建方案
- 最终形成:Vue + Vite + void(0) 工具链 的闭环
这其实是一个很聪明、很清晰的长期路线图:不再被 Babel、Terser、ESLint 等“生态外依赖”所绑定,而是自己控制工具底层、性能节奏、开发体验。
尤雨溪本人也在社区里反复提过:Vite 的未来,不只是“构建工具”,而是下一代工程化的“前端开发基建平台”。
而这张底牌,就是 Rolldown + Oxc。
你可以想想看,如果:
- Vue 生态已经在试水 Rolldown
- Vite 即将全面接入 Rolldown
- Vite 插件作者必须适配 Rolldown(否则未来会不兼容)
那就意味着:
无论你是 Vue、React、Svelte,还是用 Vite 的任何框架,都必须配合这次 “Rust 工具链” 的迁移。 否则将有可能会被踢出前端生态。
而想要参与,就必须要使用 Void(0)
的产品。
这样,尤雨溪就可以很成功的让 Void(0)
变成整个前端生态的标准了!
来源:juejin.cn/post/7511583779578642483
为什么说 AI 时代,前端开发者对前端工程化的要求更高了❓❓❓
前端工程化在前端领域地位极高,因为它系统性地解决了前端开发中效率、协作、质量、维护性等一系列核心问题,可以说是现代前端技术体系的基石。
前端工程化带来的价值可以从这四个方面看:
- 提升开发效率:
- 模块化开发:通过组件、模块拆分使开发更加清晰,复用性更强。
- 自动化构建:Webpack、Vite 等工具自动处理打包、压缩、转译等。
- 代码热更新 / HMR:开发过程中能实时看到改动,节省调试时间。
- 规范团队协作
- 代码规范检查:如 ESLint、Stylelint 统一代码风格,避免“风格大战”。
- Git 提交规范:如使用 commitlint + husky 保证提交信息标准化。
- 持续集成(CI):如 GitHub Actions、Jenkins 保证每次提交自动测试、构建。
- 提升代码质量和可维护性
- 单元测试 / 集成测试:如 Jest、Cypress 确保代码稳定可靠。
- 类型系统支持:TypeScript 保证更严格的类型检查,降低 Bug 率。
- 文档生成工具:如 Storybook、jsdoc 方便维护和阅读。
- 自动化部署与运维
- 自动化构建发布流程(CI/CD)使得上线更安全、更快速。
- 多环境配置管理(开发/测试/生产)更加方便和稳定。
总的来说,前端工程化让开发者从单纯的 “切图仔”
成长为能够参与大型系统开发的工程师。通过引入规范与工具,不仅显著提升了团队协作效率,还有效减少了开发过程中的冲突与返工,成为现代前端团队协作的 “润滑剂”
。
什么是前端工程化
前端工程化
大约在 2018 年前后在国内被广泛提出,其核心是将后端成熟的软件工程理念、工具与流程系统性地引入前端开发。
它旨在通过规范、工具链与协作流程,提升开发效率、保障交付质量、降低维护成本。前端工程化不只是技术选型,更是一种体系化、流程化的开发方式。
其核心包括代码规范、自动化构建、模块化设计、测试体系和持续集成等关键环节。通过工程化,前端从“写页面”转向“做工程”,实现了从个体开发到团队协作的转变。
它不仅优化了前端的生产方式,也推动了大型系统开发中前端角色的重要性。如今,前端工程化已成为现代前端开发不可或缺的基础能力。
为什么 AI 时代,前端工程化更重要
在 AI 时代,前端工程化不仅没有“过时”,反而变得更重要,甚至成为人机协作高效落地的关键基石。原因可以从以下几个方面理解。
虽然 AI 可以辅助生成代码、文档甚至 UI,但它并不能替代工程化体系,原因有:
- AI 的代码质量不稳定:没有工程化流程约束,容易引入 Bug 或不一致的风格。
- AI 更依赖工程规范作为提示上下文:没有良好的工程结构,AI 输出也会混乱低效。
- AI 更像“助理”,而非“工程师”:它执行快,但依然需要工程体系保障产出质量和集成稳定性。
最差的情况下有可能会删除或者修改你之前已经写好的代码,如果缺少这些工程化的手段,你甚至不知道它已经修改你的代码了,最终等到上线的时候无数的 bug 产生。
通过标准化输出让 AI 更智能,清晰的项目结构、代码规范、模块划分能让 AI 更准确地补全、修改或重构代码。例如 ESLint、TypeScript 的规则为 AI 提供了明确的限制条件,有助于生成更高质量的代码。
在生成的代码需要规范,生成完成之后更需要检验,大概的流程也有如下几个方面:
- 格式化检查(Prettier、ESLint)
- 单元测试(Jest)
- 构建打包(Vite/Webpack)
- 自动部署(CI/CD)
没有工程化,AI 产出的代码难以被真正“上线使用”。
AI 时代,对一些 CRUD 的简单要求减少了,但是对工程化提出了更高要求。
方面 | 普通时代要求 | AI 时代新挑战 |
---|---|---|
模块结构 | 清晰划分 | 需辅助 AI 理解上下文 |
代码规范 | 避免团队矛盾 | 指导 AI 输出符合规范 |
自动化测试 | 保证功能正确 | 验证 AI 代码不会引发异常 |
CI/CD 流程 | 提升上线效率 | 确保 AI 代码自动验证上线 |
前端工程化
接下来我们将分为多个小节来讲解一下前端工程化的不同技术充当着什么角色。
技术选型
在前端工程化中,技术选型看似是一道“选择题”,本质上却关系到项目的开发效率、团队协作和未来的可维护性。对于框架选择而言,建议优先考虑两个关键因素:
- 团队熟悉程度:选择你或团队最熟悉的框架,能确保在遇到复杂或疑难问题时,有人能迅速定位问题、解决“坑点”,避免因为不熟悉而拖慢项目进度。
- 市场占有率与人才生态:选择主流、活跃度高的框架(如 Vue、React),不仅能更容易找到合适的开发者,还意味着有更丰富的社区资源、第三方生态和维护支持,降低长期人力与技术风险。
统一规范
统一规范又分为代码规范、git 规范、项目规范和 UI 规范。
代码规范
统一代码规范带来的好处是显而易见的,尤其在团队开发中更显重要:
- 提升团队协作效率:统一的代码风格能让团队成员在阅读和理解他人代码时无障碍,提高沟通效率,减少因风格差异带来的理解成本。
- 降低项目维护成本:规范的代码结构更易读、易查、易改,有助于快速定位问题和后期维护。
- 促进高效 Code Review:一致的代码格式可以让审查者专注于业务逻辑本身,而非纠结于命名、缩进等细节。
- 帮助程序员自身成长:遵循良好的代码规范,有助于开发者养成系统化的编程思维,提升工程意识和代码质量。
当团队成员都严格遵循统一的代码规范时,整个项目的代码风格将保持高度一致,看别人的代码就像在看自己的代码一样自然顺畅。
为了实现这一目标,我们可以借助工具化手段来强制和规范编码行为,例如使用 ESLint 检查 JavaScript/TypeScript 的语法和代码质量,Stylelint 统一 CSS/SCSS 的书写规范,而 Prettier 则负责自动格式化各类代码,使其保持整洁一致。
这些工具不仅能在编码阶段就发现潜在问题,还能集成到 Git Hook 或 CI 流程中,确保所有提交的代码都符合团队标准。统一规范减少了 code review 中对格式问题的争论,让团队更专注于业务逻辑的优化。
更重要的是,长期在规范的约束下编程,有助于开发者养成良好的工程素养和职业习惯,提升整体开发质量和协作效率。工具是手段,习惯是目标,工程化规范最终是为了让每一位开发者都能写出“团队级”的代码。
除了上面提到的,还有很多相同功能的工具这里就不细说了。
Git 规范
Git 规范主要指团队在使用 Git 进行代码版本管理时,对分支策略、提交信息、代码合并方式等的统一约定,其目标是提升协作效率、降低沟通成本、保障版本可控。
分支管理规范可以遵循如下 Git Flow 模型:
main # 生产环境分支
develop # 开发集成分支
feature/* # 功能分支
release/* # 发布准备分支
hotfix/* # 线上紧急修复分支
分支重要,提交信息规范也更重要,一份清晰规范的提交信息对后期维护、回滚、自动发布都非常重要,好的提交信息让其他协作人员知道你这个分支具体做了什么。
推荐使用 Conventional Commits 规范,格式如下:
<type>():
常见的
类型:
类型 | 说明 |
---|---|
feat | 新增功能 |
fix | 修复 bug |
docs | 修改文档 |
style | 格式修改(不影响代码运行) |
refactor | 重构(无新功能或修复) |
test | 添加测试 |
chore | 构建过程或辅助工具变动 |
如下示例所示:
feat(login): 添加用户登录功能
fix(api): 修复接口返回字段错误
docs(readme): 完善项目使用说明
配套的工具推荐如下表所示:
工具 | 作用 |
---|---|
Commitlint | 校验提交信息是否符合格式规范 |
Husky | Git 钩子管理工具(如提交前检查) |
lint-staged | 提交前只格式化/检查改动的文件 |
Standard Version | 自动生成 changelog、自动打 tag 和版本号 |
Git 规范,是让代码“有条不紊”地流动在团队之间的交通规则,是高效协作和持续交付的基础设施。
项目规范
项目规范是对整个项目工程的结构、组织方式、开发约定的一套统一标准,它能帮助团队协作、代码维护、快速上手和高质量交付。
项目目录结构规范可以让项目保持统一、清晰的项目目录结构,有助于快速定位文件、分工协作,如下是一个简单的目录规范:
src/
├── assets/ # 静态资源(图片、字体等)
├── components/ # 可复用的基础组件
├── pages/ # 页面级组件
├── services/ # API 请求模块
├── utils/ # 工具函数
├── hooks/ # 自定义 hooks(React 项目)
├── styles/ # 全局样式
├── config/ # 配置文件(如常量、环境变量)
├── router/ # 路由配置
├── store/ # 状态管理(如 Vuex / Redux)
└── main.ts # 应用入口
这只是一个很简答也很通用的目录结构,还有很多进阶的目录结构方案。
命名方式,这个可以根据不同的团队不同的风格来指定。
部署
借助自动化流程实现一键部署或者自动部署,常用的工具主要有以下:
- GitHub Actions
- GitLab CI
- Jenkins
流程通常如下:
Push → 检查代码规范 → 构建 → 运行测试 → 上传产物 → 通知部署 → 上线
可以参考一下 Action 配置:
name: Deploy Next.js to Alibaba Cloud ECS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4.2.0
- name: Set up Node.js
uses: actions/setup-node@v4.2.0
with:
node-version: "22.11.0"
- name: Install pnpm
run: npm install -g pnpm@9.4.0
- name: Deploy to server via SSH
uses: appleboy/ssh-action@v1.2.1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
port: ${{ secrets.SERVER_PORT }}
password: ${{ secrets.SERVER_PASSWORD }}
script: |
# 显示当前环境信息
echo "Shell: $SHELL"
echo "PATH before: $PATH"
# 加载环境配置文件
source ~/.bashrc
source ~/.profile
# 如果使用 NVM,加载 NVM 环境
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# 添加常见的 Node.js 安装路径到 PATH
export PATH="$HOME/.nvm/versions/node/*/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.npm-global/bin:$PATH"
echo "PATH after: $PATH"
# 查找 npm 的位置
which npm || echo "npm still not found in PATH"
# 使用绝对路径查找 npm
NPM_PATH=$(find /usr -name npm -type f 2>/dev/null | head -1)
if [ -n "$NPM_PATH" ]; then
echo "Found npm at: $NPM_PATH"
export PATH="$(dirname $NPM_PATH):$PATH"
fi
# 确保目标目录存在
mkdir -p /home/interview-guide
cd /home/interview-guide
# 如果本地仓库不存在,进行克隆
if [ ! -d "/home/interview-guide/.git" ]; then
echo "Cloning the repository..."
# 删除可能存在的空目录内容
rm -rf /home/interview-guide/*
# 使用 SSH 方式克隆
git clone git@github.com:xun082/interview-guide.git .
else
# 确保远程 URL 使用 SSH
git remote set-url origin git@github.com:xun082/interview-guide.git
# 获取最新代码
git fetch origin main
git reset --hard origin/main
fi
# 使用找到的 npm 路径或尝试直接运行
if [ -n "$NPM_PATH" ]; then
$NPM_PATH install -g pnpm@9.4.0
$NPM_PATH install -g pm2
else
npm install -g pnpm@9.4.0
npm install -g pm2
fi
# 安装依赖
pnpm install || npm install
# 构建项目
pnpm run build || npm run build
# 重启应用
pm2 restart interview-guide || pm2 start "pnpm start" --name interview-guide || pm2 start "npm start" --name interview-guide
这段 GitHub Actions 配置实现了将 Next.js 项目自动部署到阿里云 ECS 服务器的流程。它在检测到 main
分支有新的代码提交后,自动拉取代码、安装依赖并构建项目。随后通过 SSH 远程连接服务器,拉取或更新项目代码,并使用 PM2 启动或重启应用。整个流程自动化,无需人工干预,保障部署高效、可重复。
除了 PM2 之外,我们还可以使用 Docker 镜像部署。
🛡️ 监控
前端监控是指:对 Web 应用在用户真实环境中的运行状态进行实时采集与分析,以发现性能瓶颈、错误异常和用户行为,最终帮助开发团队提升系统稳定性和用户体验。
🚦 性能监控
性能监控的目标是衡量页面加载速度、交互流畅度等关键性性能指标。
常见指标:
- 首屏加载时间(FP/FCP)
- 页面完全加载时间(Load)
- 首次输入延迟(FID)
- 长任务(Long Task)
- 慢资源加载(如图片、脚本)
它有助于定位性能瓶颈(如资源过大、阻塞脚本)、优化用户体验(如加载缓慢或白屏问题),并支持性能回归分析,及时发现上线后的性能退化。
❌ 错误监控
错误监控的目标是捕捉并上报运行时异常,辅助开发快速修复 Bug。
常见的错误类型主要有以下几个方面:
错误类型 | 示例说明 |
---|---|
JS 运行错误 | ReferenceError , TypeError 等 |
Promise 异常 | unhandledrejection |
资源加载失败 | 图片、脚本、字体 404、403 |
网络请求异常 | 接口失败、超时、断网等 |
跨域/白屏 | CORS 错误、DOM 元素为空 |
控制台报错 | console.error() 日志监控 |
用户行为异常 | 点击无响应、重复操作、高频异常等 |
假设我们使用了 fetch 进行封装,那么我们就可以对错误进行统一处理,后续我们可以再具体调用的时候根据不同的场景来传入不同的错误提示告知用户:
错误上报
数据上报是指前端在运行过程中将采集到的监控信息(性能、错误、行为等)发送到服务端的过程。它是前端监控从“收集”到“分析”的桥梁。
上报的数据类型主要有以下几个方面:
类型 | 说明 |
---|---|
性能数据 | 页面加载时间、资源加载时间、Web Vitals 等 |
错误信息 | JS 异常、Promise 异常、请求失败、白屏等 |
用户行为 | 点击、跳转、页面停留时间、操作路径等 |
自定义事件 | 特定业务事件,如支付、注册等 |
环境信息 | 浏览器版本、设备类型、操作系统、用户 IP 等 |
数据上报需要重点考虑的几个关键因素:
- 怎么上报(上报方式)
- 使用 sendBeacon、fetch、img 打点还是 WebSocket?
- 是否异步?是否阻塞主线程?
- 是否需要加密、压缩或编码?
建议:选择 异步非阻塞 且浏览器支持好的方式(优先 sendBeacon),并对数据做统一封装处理。
- 何时上报(上报时机)
- 立即上报:错误发生后马上发送(如 JS 报错)
- 延迟上报:页面稳定后延迟几秒,防止干扰首屏加载
- 页面卸载前上报:用 sendBeacon 上报用户停留数据等
- 批量上报:积累一批数据后统一发送,减少请求频率
- 定时上报:用户停留一段时间后定期上报(行为数据)
建议:根据数据类型区分时机,错误即时上报、性能延迟上报、行为数据可批量处理。
- 上报频率控制(防抖 / 节流 / 采样)
- 错误或点击频繁时可能产生大量上报请求
- 需要加防抖、节流机制,或采样上报(如只上报 10% 用户)
🔍 建议:对于高频行为(如滚动、点击),加防抖或只上报部分用户行为,避免拖垮前端或服务端。
- 异常处理与重试机制:遇到网络断开、后端失败等应支持自动重试或本地缓存,可将数据暂存至 localStorage,等网络恢复后重发
- 数据结构设计:统一字段格式、数据类型,方便服务端解析,包含上下文信息:页面 URL、用户 ID、浏览器信息、时间戳等,如下所示:
{
"type": "error",
"event": "ReferenceError",
"message": "xxx is not defined",
"timestamp": 1716280000000,
"userId": "abc123",
"url": "https://example.com/home"
}
总的来说,数据上报是前端监控的核心环节,但只有在合适的时机,用合适的方式,上报合适的数据,才能真正发挥价值。
来源:juejin.cn/post/7506414257401004071
我本是写react的,公司让我换赛道搞web3D
当你在会议室里争论需求时,
智慧工厂的数字孪生正同步着每一条产线的脉搏;
当你对着平面图想象空间时,
智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。
当你在CAD里调整参数时,
数字孪生城市的交通流正实时映射每辆车的轨迹;
当你等待客户确认方案时,
机械臂的3D仿真已预演了十万次零误差的运动路径;
当你用二维图纸解释传动原理时,
可交互的3D引擎正让客户‘拆解’每一个齿轮;
当你担心售后维修难描述时,
AR里的动态指引已覆盖所有故障点;
当你用PS拼贴效果图时,
VR漫游的业主正‘推开’你设计的每一扇门;
当你纠结墙面材质时,
光影引擎已算出了午后3点最温柔的折射角度;
从前端到Web3D,
不是换条赛道,
而是打开新维度。
韩老师说过:再牛的程序员都是从小白开始,既然开始了,就全心投入学好技术。
🔴 工具
所有的api
都可以通过threejs
官网的document
,切成中文
,去搜:
🔴 平面
⭕️ Scene 场景
场景能够让你在什么地方
、摆
放什么东西
来交给three.js来渲染
,这是你放置物体
、灯光
和摄像机
的地方
。
import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
⭕️ camera 相机
import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);
// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中
⭕️ 物体 cube
import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);
// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中
// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1); // 创建立方体的几何体 (长, 宽, 高)
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // MeshBasicMaterial 基础网格材质 ({ color: 0xffff00 }) 颜色
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial); // 创建立方体的物体 (几何体, 材质)
// 将几何体添加到场景中
scene.add(cube); // 物体添加到场景中
⭕️ 渲染 render
// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染的尺寸大小 (窗口宽度, 窗口高度)
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement); // 将webgl渲染的canvas内容添加到body
// 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染进来 (场景, 相机)
⭕️ 效果
效果是平面的:
到这里,还不是3d的,如果要加3d,要加一下控制器
。
🔴 3d
⭕️ 控制器
添加轨道。像卫星☄围绕地球🌏,环绕查看的视角:
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 目标:使用控制器查看3d物体
// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement); // 创建轨道控制器 (相机, 渲染器dom元素)
controls.enableDamping = true; // 设置控制器阻尼,让控制器更有真实效果。
function render() {
renderer.render(scene, camera); // 浏览器每渲染一帧,就重新渲染一次
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render); // 浏览器渲染下一帧的时候就会执行render函数,执行完会再次调用render函数,形成循环,每秒60次
}
render();
⭕️ 加坐标轴辅助器
// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5); // 坐标轴(size轴的大小)
scene.add(axesHelper);
⭕️ 设置物体移动
// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);
cube.position.x = 3;
// 往返移动
function render() {
cube.position.x += 0.01;
if (cube.position.x > 5) {
cube.position.x = 0;
}
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ 缩放
cube.scale.set(3, 2, 1); // xyz, x3倍, y2倍
单独设置
cube.position.x = 3;
⭕️ 旋转
cube.rotation.set(Math.PI / 4, 0, 0, "XZY"); // x轴旋转45度
单独设置
cube.rotation.x = Math.PI / 4;
⭕️ requestAnimationFrame
function render(time) {
// console.log(time);
// cube.position.x += 0.01;
// cube.rotation.x += 0.01;
// time 是一个不断递增的数字,代表当前的时间
let t = (time / 1000) % 5; // 为什么求余数,物体移动的距离就是t,物体移动的距离是0-5,所以求余数
cube.position.x = t * 1; // 0-5秒,物体移动0-5距离
// if (cube.position.x > 5) {
// cube.position.x = 0;
// }
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ Clock 跟踪事件处理动画
// 设置时钟
const clock = new THREE.Clock();
function render() {
// 获取时钟运行的总时长
let time = clock.getElapsedTime();
console.log("时钟运行总时长:", time);
// let deltaTime = clock.getDelta();
// console.log("两次获取时间的间隔时间:", deltaTime);
let t = time % 5;
cube.position.x = t * 1;
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
大概是8毫秒一次渲染时间.
⭕️ 不用算 用 Gsap动画库
// 导入动画库
import gsap from "gsap";
// 设置动画
var animate1 = gsap.to(cube.position, {
x: 5,
duration: 5,
ease: "power1.inOut", // 动画属性
// 设置重复的次数,无限次循环-1
repeat: -1,
// 往返运动
yoyo: true,
// delay,延迟2秒运动
delay: 2,
onComplete: () => {
console.log("动画完成");
},
onStart: () => {
console.log("动画开始");
},
});
gsap.to(cube.rotation, { x: 2 * Math.PI, duration: 5, ease: "power1.inOut" });
// 双击停止和恢复运动
window.addEventListener("dblclick", () => {
// console.log(animate1);
if (animate1.isActive()) {
// 暂停
animate1.pause();
} else {
// 恢复
animate1.resume();
}
});
function render() {
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ 根据尺寸变化 实现自适应
// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
// console.log("画面变化了");
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
⭕️ 用js控制画布 全屏 和 退出全屏
window.addEventListener("dblclick", () => {
const fullScreenElement = document.fullscreenElement;
if (!fullScreenElement) {
// 双击控制屏幕进入全屏,退出全屏
// 让画布对象全屏
renderer.domElement.requestFullscreen();
} else {
// 退出全屏,使用document对象
document.exitFullscreen();
}
// console.log(fullScreenElement);
});
⭕️ 应用 图形 用户界面 更改变量
// 导入dat.gui
import * as dat from "dat.gui";
const gui = new dat.GUI();
gui
.add(cube.position, "x")
.min(0)
.max(5)
.step(0.01)
.name("移动x轴")
.onChange((value) => {
console.log("值被修改:", value);
})
.onFinishChange((value) => {
console.log("完全停下来:", value);
});
// 修改物体的颜色
const params = {
color: "#ffff00",
fn: () => {
// 让立方体运动起来
gsap.to(cube.position, { x: 5, duration: 2, yoyo: true, repeat: -1 });
},
};
gui.addColor(params, "color").onChange((value) => {
console.log("值被修改:", value);
cube.material.color.set(value);
});
// 设置选项框
gui.add(cube, "visible").name("是否显示");
var folder = gui.addFolder("设置立方体");
folder.add(cube.material, "wireframe");
// 设置按钮点击触发某个事件
folder.add(params, "fn").name("立方体运动");
🔴 结语
前端的世界,
不该只有Vue
和React
——
还有WebGPU
里等待你征服的星辰大海。"
“当WebGL成为下一代前端的基础设施,愿你是最早站在三维坐标系里的那个人。”
来源:juejin.cn/post/7517209356855164978
从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓
我正在筹备一套前端工程化体系的实战课程。如果你在学习前端的过程中感到方向模糊、技术杂乱无章,那么前端工程化将是你实现系统进阶的最佳路径。它不仅能帮你建立起对现代前端开发的整体认知,还能提升你在项目开发、协作规范、性能优化等方面的工程能力。
✅ 本课程覆盖构建工具
、测试体系
、脚手架
、CI/CD
、Docker
、Nginx
等核心模块,内容体系完整,贯穿从开发到上线的全流程。每一章节都配有贴近真实场景的企业级实战案例,帮助你边学边用,真正掌握现代团队所需的工程化能力,实现从 CRUD 开发者到工程型前端的跃迁。
详情请看前端工程化实战课程
学完本课程,对你的简历和具体的工作能力都会有非常大的提升。如果你对此项目感兴趣,或者课程感兴趣,可以私聊我微信 yunmz777
今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。
我的技术栈
首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。
React、Typescript、React Native、mysql、prisma、NestJs、Redis、前端工程化。
React
React 这个框架我花的时间应该是比较多的了,在校期间已经读了一遍源码了,对这些原理已经基本了解了。在随着技术的继续深入,今年毕业后又重新开始阅读了一遍源码,对之前的认知有了更深一步的了解。
也写了比较多跟 React 相关的文章,包括设计模式,原理,配套生态的使用等等都有一些涉及。
在状态管理方面,redux,zustand 我都用过,尤其在 Zustand 的使用上,我特别喜欢 Zustand,它使得我能够快速实现全局状态管理,同时避免了传统 Redux 中繁琐的样板代码,且性能更优。也对 Zustand 有比较深入的了解,也对其源码有过研究。
NextJs
Next.js 是一个基于 React 的现代 Web 开发框架,它为开发者提供了一系列强大的功能和工具,旨在优化应用的性能、提高开发效率,并简化部署流程。Next.js 支持多种渲染模式,包括服务器端渲染(SSR)、静态生成(SSG)和增量静态生成(ISR),使得开发者可以根据不同的需求选择合适的渲染方式,从而在提升页面加载速度的同时优化 SEO。
在路由管理方面,Next.js 采用了基于文件系统的路由机制,这意味着开发者只需通过创建文件和文件夹来自动生成页面路由,无需手动配置。这种约定优于配置的方式让路由管理变得直观且高效。此外,Next.js 提供了动态路由支持,使得开发者可以轻松实现复杂的 URL 结构和参数化路径。
Next.js 还内置了 API 路由,允许开发者在同一个项目中编写后端 API,而无需独立配置服务器。通过这种方式,前后端开发可以在同一个代码库中协作,大大简化了全栈开发流程。同时,Next.js 对 TypeScript 提供了原生支持,帮助开发者提高代码的可维护性和可靠性。
Typescript
今年所有的项目都是在用 ts 写了,真的要频繁修改的项目就知道用 ts 好处了,有时候用 js 写的函数修改了都不知道怎么回事,而用了 ts 之后,哪里引用到的都报红了,修改真的非常方便。
今年花了一点时间深入学习了一下 Ts 类型,对一些高级类型以及其实现原理也基本知道了,明年还是多花点时间在类型体操上,除了算法之外,感觉类型体操也可以算得上是前端程序员的内功心法了。
React Native
不得不说,React Native 不愧是接活神器啊,刚学完之后就来了个安卓和 ios 的私活,虽然没有谈成。
React Native 和 Expo 是构建跨平台移动应用的两大热门工具,它们都基于 React,但在功能、开发体验和配置方式上存在一些差异。React Native 是一个开放源代码的框架,允许开发者使用 JavaScript 和 React 来构建 iOS 和 Android 原生应用。Expo 则是一个构建在 React Native 之上的开发平台,它提供了一套工具和服务,旨在简化 React Native 开发过程。
React Native 的核心优势在于其高效的跨平台开发能力。通过使用 React 语法和组件,开发者能够一次编写应用的 UI 和逻辑,然后部署到 iOS 和 Android 平台。React Native 提供了对原生模块的访问,使开发者能够使用原生 API 来扩展应用的功能,确保性能和用户体验能够接近原生应用。
Expo 在此基础上进一步简化了开发流程。作为一个开发工具,Expo 提供了许多内置的 API 和组件,使得开发者无需在项目中进行繁琐的原生模块配置,就能够快速实现设备的硬件访问功能(如摄像头、位置、推送通知等)。Expo 还内置了一个开发客户端,使得开发者可以实时预览应用,无需每次都进行完整的构建和部署。
另外,Expo 提供了一个完全托管的构建服务,开发者只需将应用推送到 Expo 服务器,Expo 就会自动处理 iOS 和 Android 应用的构建和发布。这大大简化了应用的构建和发布流程,尤其适合不想处理复杂原生配置的开发者。
然而,React Native 和 Expo 也有各自的局限性。React Native 提供更大的灵活性和自由度,开发者可以更自由地集成原生代码或使用第三方原生库,但这也意味着需要更多的配置和维护。Expo 则封装了很多功能,简化了开发,但在需要使用某些特定原生功能时,开发者可能需要“弹出”Expo 的托管环境,进行额外的原生开发。
样式方案的话我使用的是 twrnc,大部分组件都是手撸,因为有 cursor 和 chatgpt 的加持,开发效果还是杠杠的。
rn 原理也争取明年能多花点时间去研究研究,不然对着盲盒开发还是不好玩。
Nestjs
NestJs 的话没啥好说的,之前也都写过很多篇文章了,感兴趣的可以直接观看:
对 Nodejs 的底层也有了比较深的理解了:
Prisma & mysql
Prisma 是一个现代化的 ORM(对象关系映射)工具,旨在简化数据库操作并提高开发效率。它支持 MySQL 等关系型数据库,并为 Node.js 提供了类型安全的数据库客户端。在 NestJS 中使用 Prisma,可以让开发者轻松定义数据库模型,并通过自动生成的 Prisma Client 执行类型安全的查询操作。与 MySQL 配合时,Prisma 提供了一种简单、直观的方式来操作数据库,而无需手动编写复杂的 SQL 查询。
Prisma 的核心优势在于其强大的类型安全功能,所有的数据库操作都能通过 Prisma Client 提供的自动生成的类型来进行,这大大减少了代码中的错误,提升了开发的效率。它还包含数据库迁移工具 Prisma Migrate,能够帮助开发者方便地管理数据库结构的变化。此外,Prisma Client 的查询 API 具有很好的性能,能够高效地执行复杂的数据库查询,支持包括关系查询、聚合查询等高级功能。
与传统的 ORM 相比,Prisma 使得数据库交互更加简洁且高效,减少了配置和手动操作的复杂性,特别适合在 NestJS 项目中使用,能够与 NestJS 提供的依赖注入和模块化架构很好地结合,提升整体开发体验。
Redis
Redis 和 mysql 都仅仅是会用的阶段,目前都是直接在 NestJs 项目中使用,都是已经封装好了的,直接传参调用就好了:
import { Injectable, Inject, OnModuleDestroy, Logger } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";
import { ObjectType } from "../types";
import { isObject } from "@/utils";
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}
onModuleDestroy(): void {
this.redisClient.disconnect();
}
/**
* @Description: 设置值到redis中
* @param {string} key
* @param {any} value
* @return {*}
*/
public async set(
key: string,
value: unknown,
second?: number
): Promise<Result<"OK", ClientContext> | null> {
try {
const formattedValue = isObject(value)
? JSON.stringify(value)
: String(value);
if (!second) {
return await this.redisClient.set(key, formattedValue);
} else {
return await this.redisClient.set(key, formattedValue, "EX", second);
}
} catch (error) {
this.logger.error(`Error setting key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 获取redis缓存中的值
* @param key {String}
*/
public async get(key: string): Promise<string | null> {
try {
const data = await this.redisClient.get(key);
return data ? data : null;
} catch (error) {
this.logger.error(`Error getting key ${key} from Redis`, error);
return null;
}
}
/**
* @Description: 设置自动 +1
* @param {string} key
* @return {*}
*/
public async incr(
key: string
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.incr(key);
} catch (error) {
this.logger.error(`Error incrementing key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 删除redis缓存数据
* @param {string} key
* @return {*}
*/
public async del(key: string): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.del(key);
} catch (error) {
this.logger.error(`Error deleting key ${key} from Redis`, error);
return null;
}
}
/**
* @Description: 设置hash结构
* @param {string} key
* @param {ObjectType} field
* @return {*}
*/
public async hset(
key: string,
field: ObjectType
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.hset(key, field);
} catch (error) {
this.logger.error(`Error setting hash for key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 获取单个hash值
* @param {string} key
* @param {string} field
* @return {*}
*/
public async hget(key: string, field: string): Promise<string | null> {
try {
return await this.redisClient.hget(key, field);
} catch (error) {
this.logger.error(
`Error getting hash field ${field} from key ${key} in Redis`,
error
);
return null;
}
}
/**
* @Description: 获取所有hash值
* @param {string} key
* @return {*}
*/
public async hgetall(key: string): Promise<Record<string, string> | null> {
try {
return await this.redisClient.hgetall(key);
} catch (error) {
this.logger.error(
`Error getting all hash fields from key ${key} in Redis`,
error
);
return null;
}
}
/**
* @Description: 清空redis缓存
* @return {*}
*/
public async flushall(): Promise<Result<"OK", ClientContext> | null> {
try {
return await this.redisClient.flushall();
} catch (error) {
this.logger.error("Error flushing all Redis data", error);
return null;
}
}
/**
* @Description: 保存离线通知
* @param {string} userId
* @param {any} notification
*/
public async saveOfflineNotification(
userId: string,
notification: any
): Promise<void> {
try {
await this.redisClient.lpush(
`offline_notifications:${userId}`,
JSON.stringify(notification)
);
} catch (error) {
this.logger.error(
`Error saving offline notification for user ${userId}`,
error
);
}
}
/**
* @Description: 获取离线通知
* @param {string} userId
* @return {*}
*/
public async getOfflineNotifications(userId: string): Promise<any[]> {
try {
const notifications = await this.redisClient.lrange(
`offline_notifications:${userId}`,
0,
-1
);
await this.redisClient.del(`offline_notifications:${userId}`);
return notifications.map((notification) => JSON.parse(notification));
} catch (error) {
this.logger.error(
`Error getting offline notifications for user ${userId}`,
error
);
return [];
}
}
/**
* 获取指定 key 的剩余生存时间
* @param key Redis key
* @returns 剩余生存时间(秒)
*/
public async getTTL(key: string): Promise<number> {
return await this.redisClient.ttl(key);
}
}
前端工程化
前端工程化这块花了很多信息在 eslint、prettier、husky、commitlint、github action 上,现在很多项目都是直接复制之前写好的过来就直接用。
后续应该是投入更多的时间在性能优化、埋点、自动化部署上了,如果有机会的也去研究一下 k8s 了。
全栈性价比最高的一套技术
最近刷到一个帖子,讲到了
我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:
- NextJs
- React Native
- prisma
- NestJs
- taro (目前还不会,如果有需求就会去学)
剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)
总结
学无止境,任重道远。
最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:
如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777
,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。
来源:juejin.cn/post/7451483063568154639
京东购物车动效实现:贝塞尔曲线的妙用
前言
大家好,我是奈德丽。前两天在逛京东想买Pocket 3的时候,注意到了它的购物车动效,当点击"加入购物车"按钮时,一个小红球从商品飞入购物车,我觉得很有意思,于是花了点时间来研究。
实现效果
看了图才知道我在讲什么,那么先看Gif吧!
代码演示
代码已经上传到了码上掘金,感兴趣的可以自行查看,文章中没有贴全部代码了,主要讲讲思路,
code.juejin.cn/pen/7503150…
实现思路
下面这个思路,小白也能会,我们将通过以下几个步骤来实现这个效果:
画页面——>写逻辑实现动画效果
好了,废话不多说,开始进入正题
第一步:先让AI帮我们写出来UI结构
像我们这种工作1坤年以上的切图仔,能偷懒当然偷懒啦,这种画页面的活可以丢给AI来干了,下面是Taro帮我生成的页面部分,没什么难点,就是一些普普通通的页面元素。
<template>
<div class="rolling-ball-container">
<!-- 商品列表 -->
<div class="item-list">
<div class="item" v-for="item in 10" :key="item">
<div class="product-card">
<div class="product-tag">秒杀</div>
<div class="product-image">
<img src="/product.jpg" alt="商品图片" />
</div>
<div class="product-info">
<div class="product-title">大疆 DJI Osmo Pocket 3 一英寸口袋云台相机</div>
<div class="product-features">
<span class="feature-tag">三轴防抖</span>
<span class="feature-tag">防抖稳定</span>
<span class="feature-tag">高清画质</span>
</div>
<div class="product-price">
<span class="price-symbol">¥</span>
<span class="price-value">4788</span>
<span class="price-original">¥4899</span>
</div>
<div class="product-meta">
<span class="delivery-time">24分钟达</span>
<span class="rating">好评率96%</span>
</div>
<div class="product-shop">京东之家-凯德汇新店</div>
</div>
<div class="add-to-cart" @click="startRolling($event)">+</div>
</div>
</div>
</div>
<!-- 购物车图标 -->
<div class="point end-point">
<div style="position: relative;">
<img src="/cart.png" />
<div class="cart-count">{{ totalCount }}</div>
</div>
</div>
<!-- 小球容器 -->
<div
v-for="(ball, index) in balls"
:key="index"
class="ball"
v-show="ball.show"
:style="getBallStyle(ball)"
></div>
</div>
</template>
第二步:设计小球数据模型
有了页面元素了,我们需要创建小球数组和计数器
import { reactive, ref } from 'vue';
// 购物车商品计数
const totalCount = ref(0);
// 创建小球数组(预先创建3个小球以应对连续点击)
const balls = reactive(Array(3).fill(0).map(() => ({
show: false, // 是否显示
startX: 0, // 起点X坐标
startY: 0, // 起点Y坐标
endX: 0, // 终点X坐标
endY: 0, // 终点Y坐标
pathX: 0, // 路径X偏移量
pathY: 0, // 路径Y偏移量
progress: 0 // 动画进度
})));
为什么小球要用一个数组来存储呢?因为我看到京东上用户是可以连续点击+号将商品加入购入车的,页面上可以同时存在很多个飞行的小球。
第三步:实现动画触发函数
当用户点击"+"按钮时,我们需要计算起点和终点坐标,然后启动动画,这儿有一个细节,为了让小球刚好落到在购物车中间,对终点坐标进行了微调。
// 开始滚动动画
const startRolling = (event: MouseEvent) => {
// 获取起点和终点元素
const startPoint = event.currentTarget as HTMLElement;
const endPoint = document.querySelector('.end-point') as HTMLElement;
if (startPoint && endPoint) {
// 找到一个可用的小球
const ball = balls.find(ball => !ball.show);
if (ball) {
// 获取起点位置
const startRect = startPoint.getBoundingClientRect();
ball.startX = startRect.left + startRect.width / 2;
ball.startY = startRect.top + startRect.height / 2;
// 获取终点位置
const endRect = endPoint.getBoundingClientRect();
const endX = endRect.left + endRect.width / 2;
const endY = endRect.top + endRect.height / 2;
// 微调终点位置
ball.endX = endX - 4;
ball.endY = endY - 7;
// 设置路径偏移量
ball.pathX = 0;
ball.pathY = 100;
// 显示小球并重置进度
ball.show = true;
ball.progress = 0;
// 使用requestAnimationFrame实现动画
let startTime = Date.now();
const duration = 400; // 动画持续时间(毫秒)
function animate() {
const currentTime = Date.now();
const elapsed = currentTime - startTime;
ball.progress = Math.min(elapsed / duration, 1);
if (ball.progress < 1) {
requestAnimationFrame(animate);
} else {
// 动画结束后隐藏小球
setTimeout(() => {
ball.show = false;
}, 100);
}
}
requestAnimationFrame(animate);
// 增加购物车商品数量
totalCount.value++;
}
}
};
第四步:使用贝塞尔曲线计算小球轨迹
点击"+"按钮,不能让小球做自由落体运动吧,那是伽利略研究的,你看这自由落体好看嘛,指定不行,要是长这样,那东哥的商城还能卖出去东西吗?Hah
为了不让它自由落体,给它一个向左的偏移量100px
// 获取小球样式
const getBallStyle = (ball: any) => {
if (!ball.show) return {};
// 使用二次贝塞尔曲线计算路径
const t = ball.progress;
const mt = 1 - t;
// 判断起点和终点是否在同一垂直线上
const isVertical = Math.abs(ball.startX - ball.endX) < 20;
// 计算控制点(确保有弧度)
let controlX, controlY;
if (isVertical) {
// 如果在同一垂直线上,向左偏移一定距离
controlX = ball.startX - 100;
controlY = (ball.startY + ball.endY) / 2;
} else {
// 否则使用向左偏移
controlX = (ball.startX + ball.endX) / 2 - 100;
controlY = (ball.startY + ball.endY) / 2 + (ball.pathY || 100);
}
// 二次贝塞尔曲线公式
const x = mt * mt * ball.startX + 2 * mt * t * controlX + t * t * ball.endX;
const y = mt * mt * ball.startY + 2 * mt * t * controlY + t * t * ball.endY;
return {
left: `${x}px`,
top: `${y}px`,
transform: `rotate(${ball.progress * 360}deg)` // 添加旋转效果
};
};
技术要点解析
1. 贝塞尔曲线原理
贝塞尔曲线是一种参数化曲线,广泛应用于计算机图形学。二次贝塞尔曲线由三个点定义:起点P₀、控制点P₁和终点P₂。
曲线上任意点的坐标可以通过以下公式计算:
B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂ (0 ≤ t ≤ 1)
在我们的实现中,通过调整控制点的位置,可以控制曲线的形状,从而实现小球的抛物线运动效果。
2. requestAnimationFrame的优势
与setTimeout或setInterval相比,requestAnimationFrame有以下优势:
- 性能更好:浏览器会在最合适的时间(通常是下一次重绘之前)执行回调函数,避免不必要的重绘
- 节能:当页面不可见或最小化时,动画会自动暂停,节省CPU资源
- 更流畅:与显示器刷新率同步,动画更平滑
3. 动态计算元素位置
我们使用getBoundingClientRect()
方法获取元素在视口中的精确位置,这确保了无论页面如何滚动或调整大小,动画始终能准确地从起点到达终点。
总结
通过这个小球飞入购物车的动画效果,我们不仅提升了用户体验,还学习了:
- 如何使用贝塞尔曲线创建平滑动画
- 如何用requestAnimationFrame实现高性能动画
- 如何动态计算元素位置
- 如何使用rem单位实现移动端适配
这个小小的交互设计虽然看起来简单,但能大大提升用户体验,让你的电商网站更加生动有趣。从京东商城的灵感到实际代码实现,我们完成了一个专业级别的交互效果。
恩恩……懦夫的味道
来源:juejin.cn/post/7502647033401704484
工作两年,最后从css转向tailwind了!
菜鸟上班已经两年了,从一个对技术充满热情的小伙子,变成了一个职场老鸟了。自以为自己在不停的学习,但是其实就是学一些零碎的知识点,比如:vue中什么东西没见过、js什么特性没用过、css新出了个啥 ……
菜鸟感觉自己也出现了惰性,就是暂时用不上的或者学习成本比较大的,就直接收藏了,想着后面再来学习;然后那些很快能接收有用的小的知识点,就感觉看过几次就收藏了,后面有用,就来收藏里面翻一下就行!
但是菜鸟最近再来回想才发现,这些其实都是虚的,程序员最重要的应该是思维模式,以及如何把学的东西、好用的东西用起来,找到应用场景,而不是到时候再去找。
正如标题所说,菜鸟其实很早就知道css原子化,但是一直都走不出自己的舒适圈,感觉就写点css也挺好,为什么还要花力气去记别人想好的类名?要是一直用这些,岂不是css知识都忘记完了?
直到我们公司的大佬来了之后,力推tailwind
,而菜鸟感觉和大佬的差距真的很大,所以又激起了菜鸟想要学习的兴趣!
怎么从css过渡到tailwind
菜鸟在之前,是很不想使用tailwind
的,因为菜鸟感觉里面很多类名需要去记,而且和我之前取类名的方式也不一样!相信大部分人都和菜鸟一样,在用tailwind
之前,取类名一般都是和包裹的内容相关的名字,例如:contentBox、title、asideBox ……
前期使用不熟的时候直接打开官网就行:http://www.tailwindcss.cn/docs/instal…
菜鸟告诉大家一个办法,就是别想着去记类名,直接你想要用什么css属性,直接点击搜索即可,敲入你想使用的属性
多用几次,自然就记住了,而且现在编译器有提示的。用了tailwind
之后,只能说句真香,因为再也不会有怎么取名以及有重名的困扰了!
tailwind yyds
一开始菜鸟用tailwind
,感觉也不是很自由啊!
菜鸟就感觉这个也太low了吧,我要是想用别的值怎么办?直到菜鸟看到了这个
基本上有了这个,就可以天下无敌了,想多少就多少,这就是自由的感觉!
反正菜鸟基本上用的都是这个,不管是颜色还是大小,除非比较好记的,例如:w-1、w-2、p-1、p-2、m-1、mr-1 ……
tailwind 自定义类名
有一个问题,就是当类名太多的时候,感觉也不是很好看,这个时候就要用到复杂一点的tailwind
,见文档:http://www.tailwindcss.cn/docs/reusin…
很多地方都用到一样的样式,就适合这种方式!不然直接多写几个类名也不是不能接受!
@layer
这个@layer components
是避免样式冲突和被覆盖的作用,菜鸟感觉不好理解,但是你肯定不会去重写tailwind
的类名,至于有没有树摇优化那就是菜鸟没有涉猎了,反正就当默认写法比较好理解,一般也确实就是这样写。
这里也可以看看tailwind4
的官网,感觉说得清楚一点:tailwindcss.com/docs/adding…
当然有懂的读者,可以指点江山,激扬文字!
更多函数或指令
tailwind
中不止有@layer
、@apply
,只是 菜鸟主要就用了这两个,更多见官网:tailwindcss.com/docs/functi…
类名太多,团队规范
当一个元素类名比较多时,每个人的想法都不一样,那么类名就会比较杂乱,可能每个人都不一样,看着就不是很好,这个时候就要使用自动格式化工具了,让每个人的类名排列顺序都是一样,也避免了不少冲突!
只要使用了prettier
就可以使用这个,关于prettier
的知识可以见:vue3+vite+eslint|prettier+elementplus+国际化+axios封装+pinia
使用tailwind不会忘记css,更是加强css
菜鸟之前对tailwind
的误解有点深,其实使用tailwind
根本不会降低我们的css水平,相反,你平时多逛逛tailwind
官网,反而能发现一些你从未使用过或者使用很少的css属性,你会用tailwind
实现,其实就是css会实现,反正都可以增加你对css某个属性的理解,且tailwind
还附带了效果示例!
Trae 对 tailwind 的支持
之前的代码
<el-button
:loading="loading"
size="large"
type="primary"
s =tyle="width: 100%"
@click.prevent="handleLogin"
>
<span v-if="!loading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
实现效果
感觉Trae对tailwind
的支持挺好的,一些简单的效果都可以快速实现!
tailwind 可以替代 scss 等
tailwind4
中有明确的说明,见:tailwindcss.com/docs/compat…
菜鸟只能说tailwind
的目标很宏大!
总结
tailwind
使用不难,所以菜鸟也没啥可以写得很多或者很复杂的,菜鸟只是希望这个经历可以让各位新手赶紧掌握tailwind
,不是css用不起,而是tailwind
更有性价比!
来源:juejin.cn/post/7501147702667952168
我们又上架了一个鸿蒙项目-止欲
我们又上架了一个鸿蒙项目-止欲
止欲介绍
止欲是一款休闲类的鸿蒙元服务,希望可以通过冥想让繁杂的生活慢下来、静下来。
《止欲》从立项到上架总过程差不多两个月,主要都是我们青蓝的小伙伴在工作止欲抽空完成的,已经实属不易了,我们主要开发者都是 00 后,最年轻的开发者也是才 19 岁。
立项时间是:2025-04-08
上架时间是:2025-06-03
止欲同时也是我们青蓝逐码组织上架的第三个作品了,每个作品都是由初入职场、甚至大学还没有毕业的小伙伴高度参与!
git 日志一览
项目技术细节
项目架构
Serenity/Application/
├── entry/ # 主模块
│ ├── src/main/
│ │ ├── ets/ # TypeScript源码
│ │ │ ├── entryability/ # 应用入口能力
│ │ │ ├── entryformability/ # 服务卡片能力
│ │ │ ├── pages/ # 页面文件
│ │ │ ├── view/ # UI组件
│ │ │ ├── utils/ # 工具类
│ │ │ ├── model/ # 数据模型
│ │ │ ├── const/ # 常量定义
│ │ │ └── navigationStack/ # 导航栈管理
│ │ └── resources/ # 资源文件
│ └── module.json5 # 模块配置
├── EntryCard/ # 服务卡片模块
├── AppScope/ # 应用级配置
└── oh-package.json5 # 依赖管理
技术栈
- 开发语言: ArkTS (TypeScript)
- UI 框架: ArkUI
- 构建工具: Hvigor
- 包管理: ohpm
核心开发套件 (Kit)
本项目使用了多个 HarmonyOS 官方开发套件:
套件名称 | 用途 | 主要 API |
---|---|---|
@kit.ArkUI | UI 框架和导航 | AtomicServiceNavigation, window |
@kit.BasicServicesKit | 基础服务 | BusinessError, request |
@kit.MediaLibraryKit | 媒体库访问 | photoAccessHelper |
@kit.CoreFileKit | 文件操作 | fileIo |
@kit.ImageKit | 图像处理 | image.createImageSource |
@kit.PerformanceAnalysisKit | 性能分析 | hilog |
@kit.AbilityKit | 应用能力 | UIAbility, abilityAccessCtrl |
开发环境要求
- HarmonyOS SDK: 5.0.1(13) 或更高版本
- DevEco Studio: 5.0 或更高版本
- 编译目标: HarmonyOS
开发细节
开始立项
分析如何选型
暴躁起来了
成功上架
后续计划
- 接入登录
- 接入端云一体
- 增加趣味性功能
- 代码开源-分享教程
总结
如果你兴趣想要了解更多的鸿蒙应用开发细节和最新资讯,甚至你想要做出一款属于自己的应用!欢迎在评论区留言或者私信或者看我个人信息,可以加入技术交流群。
来源:juejin.cn/post/7511779749967347747
Chrome AI:颠覆网页开发的全新黑科技
Chrome AI 长啥样
废话不多说,让我们直接来看一个示例:
async function askAi(question) {
if (!question) return "你倒是输入问题啊"
// 检查模型是否已下载(模型只需下载一次,就可以供所有网站使用)
const canCreate = await window.ai.canCreateTextSession()
if (canCreate !== "no") {
// 创建一个会话进程
const session = await window.ai.createTextSession()
// 向 AI 提问
const result = await session.prompt(question)
// 销毁会话
session.destroy()
return result
}
return "模型都还没下载好,你问个蛋蛋"
}
askAi("玩梗来说,世界上最好的编程语言是啥").then(console.log)
//打印: **Python 语言:程序员的快乐源泉!**
可以看到这些浏览器原生 AI
接口是挂在 window.ai
对象下面的,浏览器自带 AI
模型(要下载),无需消耗开发者的资金去调用 OpenAI API
或者是 文心一言 API
等。
由于没有成本限制,想象空间极大扩展。你可以将智能融入网页的每一个环节。例如,实时翻译,传统的 i18n
只能映射静态字符串来支持多语言,对于后端传过来的字符串毫无办法,现在可以交给 AI
实时翻译并展示。
未来,这个浏览器 AI
标准接口将不仅限于 Chrome 和 PC 端,其他浏览器厂商也会跟进,手机也将拥有本地运行小模型的浏览器。
Chrome AI 接口文档
我们刚刚看到了 Chrome AI
的调用示例,现在让我们看一下完整的 Chrome
文档。我将用 TypeScript
和注释方式展示,这些类型和注释是我手动编写的,全网独一无二,赶紧收藏:
declare global {
interface Window {
readonly ai: AI;
}
interface AI {
/**
* 判断模型是否准备好了
* @example
* ```js
* const availability = await window.ai.canCreateTextSession()
* if (availability === 'readily') {
* console.log('模型已经准备好了')
* } else if (availability === 'after-download') {
* console.log('模型正在下载中')
* } else {
* console.log('模型还没下载')
* }
* ```
*/
canCreateTextSession(): Promise<AIModelAvailability>;
/**
* 创建一个文本生成会话进程
* @param options 会话配置
* @example
* ```js
* const session = await window.ai.createTextSession({
* topK: 50, // 生成文本的多样性,越大越多样
* temperature: 0.8 // 生成文本的创造性,越大越随机
* })
*
* const text = await session.prompt('今天天气怎么样?')
* console.log(text)
* ```
*/
createTextSession(options?: AITextSessionOptions): Promise<AITextSession>;
/**
* 获取默认的文本生成会话配置
* @example
* ```js
* const options = await window.ai.defaultTextSessionOptions()
* console.log(options) // { topK: 50, temperature: 0.8 }
* ```
*/
defaultTextSessionOptions(): Promise<AITextSessionOptions>;
}
/**
* AI模型的可用性
* - `readily`:模型已经准备好了
* - `after-download`:模型正在下载中
* - `no`:模型还没下载
*/
type AIModelAvailability = 'readily' | 'after-download' | 'no';
interface AITextSession {
/**
* 询问 AI 问题, 返回 AI 的回答
* @param input 输入文本, 询问 AI 的问题
* @example
* ```js
* const session = await window.ai.createTextSession()
* const text = await session.prompt('今天天气怎么样?')
* console.log(text)
* ```
*/
prompt(input: string): Promise<string>;
/**
* 询问 AI 问题, 以流的形式返回 AI 的回答
* @param input 输入文本, 询问 AI 的问题
* @example
* ```js
* const session = await window.ai.createTextSession()
* const stream = session.promptStreaming('今天天气怎么样?')
* let result = ''
* let previousLength = 0
*
* for await (const chunk of stream) {
* const newContent = chunk.slice(previousLength)
* console.log(newContent) // AI 的每次输出
* previousLength = chunk.length
* result += newContent
* }
*
* console.log(result) // 最终的 AI 回答(完整版)
*/
promptStreaming(input: string): ReadableStream;
/**
* 销毁会话
* @example
* ```js
* const session = await window.ai.createTextSession()
* session.destroy()
* ```
*/
destroy(): void;
/**
* 克隆会话
* @example
* ```js
* const session = await window.ai.createTextSession()
* const cloneSession = session.clone()
* const text = await cloneSession.prompt('今天天气怎么样?')
* console.log(text)
* ```
*/
clone(): AITextSession;
}
interface AITextSessionOptions {
/**
* 生成文本的多样性,越大越多样,正整数,没有范围
*/
topK: number;
/**
* 生成文本的创造性,越大越随机,0-1 之间的小数
*/
temperature: number;
}
}
如何启用 Chrome AI
准备工作
- 下载最新 Chrome Dev 版或 Chrome Canary 版。(版本号不低于
128.0.6545.0
) - 确保你的电脑有
22G
的可用存储空间。 - 很科学的网络
启用 Gemini Nano 和 Prompt API
- 打开
Chrome
, 在地址栏输入:chrome://flags/#optimization-guide-on-device-model
,选择enable BypassPerfRequirement
,这步是绕过性能检查,确保Gemini Nano
能顺利下载。 - 再输入
chrome://flags/#prompt-api-for-gemini-nano
,选择enable
。 - 重启
Chrome
浏览器。
确认 Gemini Nano 是否可用
- 按
F12
打开开发者工具, 在控制台输入await window.ai.canCreateTextSession()
,如果返回readily
,就说明 OK 了。 - 如果上面的步骤不成功,重启
Chrome
后继续下面的操作:
- 新开一个标签页,输入
chrome://components
- 找到
Optimization Guide On Device Model
,点击Check for update
,等待一个世纪直到Status - Component updated
出现就是模型下载完成。(模型版本号不低于2024.5.21.1031
)
- 新开一个标签页,输入
- 模型下载完成后, 再次在开发者工具的控制台中输入
await window.ai.canCreateTextSession()
,如果这次返回readily
,那就 OK 了。 - 如果还是不行,可以等一会儿再试。多次尝试后仍然失败,请关闭此文章🐶。
思考
AI
最近两年可谓是爆发式增长,从 GPT-3
开始,笔者就一直在使用 AI
产品,如 Github copilot
。ChatGPT
推出后,我迅速开发了一个 GPT-Runner vscode
扩展,用于勾选代码文件进行对话。
我一直在思考,AI
能给网页产品带来哪些变革?例如,有没有可能出现一个 AI
组件库,将 AI
智能赋予组件,如 input
框猜测用户下一步输入,或 table
组件实现自然语言搜索和数据拼装。
与 AI
相关的技术通常需要额外的计算成本,企业主和用户支付意愿低。如果能利用本地算力,就无需额外花费。这个场景现在似乎在慢慢实现。
作为开发者,我们正在迎来 AI
全面赋能网页操作的时代。让我们积极拥抱变化,向老板展示更多的迭代需求,找到前端就业的新增长点。
如果本文章感兴趣者众多,将考虑使用这个 AI
接口实现兼容 OpenAI API
规范,这样你可以不用花钱,不用装 Docker
,直接使用浏览器算力和油猴插件免费使用各类开源 chat web ui
,如在线版的 Chat-Next-Web。
彩蛋
仔细观察 window.ai.createTextSession
,你会发现它为什么不叫 window.ai.createSession
?我猜测未来可能会有 text-to-speech
模型、 speech-to-text
模型、text-to-image
模型、image-to-text
模型,或者更多惊喜。
这不是随便猜测,我是在填写 Chrome AI preview
邀请表时看到的选项。敬请期待吧,各位前端开发er。
来源:juejin.cn/post/7384997062415843339
为了不让同事看到我的屏幕,我写了一个 Chrome 插件
那天下午,我正在浏览一些到岛国前端技术文档,突然听到身后传来脚步声。我下意识地想要切换窗口,但已经来不及了——我的同事小张已经站在了我身后。"咦,你在看什么?"他好奇地问道。我尴尬地笑了笑,手忙脚乱地想要关闭页面。那一刻,我多么希望有一个快捷键,能瞬间让整个屏幕变得模糊,这样就不会有人看到我正在浏览的内容了。
于是乎我想:为什么不开发一个 Chrome 插件,让用户能够一键模糊整个网页呢?这样不仅能保护隐私,还能避免类似的尴尬情况。
开发过程
说干就干,我开始了 Web Blur 插件的开发。这个插件的核心功能很简单:
- 一键切换:使用快捷键(默认 Ctrl+B)快速开启/关闭模糊效果
- 可调节的模糊程度:根据个人喜好调整模糊强度
- 记住设置:自动保存用户的偏好设置
技术实现
1.首先,我们需要在 manifest.json 中声明必要的权限:
"manifest_version": 3,
"name": "Web Blur",
"version": "1.0",
"permissions": [
"activeTab",
"storage",
"commands"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"128": "images/icon.png"
}
},
"commands": {
"toggle-blur": {
"suggested_key": {
"default": "Ctrl+Shift+B"
},
"description": "Toggle blur effect"
}
}
}
2. 实现模糊效果
function applyBlur(amount) {
const style = document.createElement('style');
style.id = 'web-blur-style';
style.textContent = `
body {
filter: blur(${amount}px) !important;
transition: filter 0.3s ease;
}
`;
document.head.appendChild(style);
}
// 移除模糊效果
function removeBlur() {
const style = document.getElementById('web-blur-style');
if (style) {
style.remove();
}
}
3. 快捷键控制
if (command === 'toggle-blur') {
chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id, {action: 'toggleBlur'});
});
}
});
4. 用户界面
5px
Current: Ctrl+Shift+B
5. 设置持久化
function saveSettings(settings) {
chrome.storage.sync.set({settings}, () => {
console.log('Settings saved');
});
}
// 加载设置
function loadSettings() {
chrome.storage.sync.get(['settings'], (result) => {
if (result.settings) {
applySettings(result.settings);
}
});
}
以后可以愉快的学技术辣
来源:juejin.cn/post/7509042833152851978
被问tsconfig.json 和 tsconfig.node.json 有什么作用,我懵了……
背景
事情是这样的,前几天在项目例会上,领导随口问了我我一个看似简单的问题:
“我们项目里有tsconfig.json
和 tsconfig.node.json
,它们有什么作用?”
活久见,我从来没注意过这个细节,我内心无语,问这种问题对项目有什么用!但机智的我还是回答上来了:不都是typescript的配置文件么。
领导肯定了我的回答,又继续问,那为什么项目中有两个配置文件呢?我机智的说,我理解的不深,领导您讲讲吧,我学习一下。
tsconfig.json 是干嘛的?
说白了,tsconfig.json
就是 告诉 TypeScript:我要用哪些规则来“看懂”和“检查”我写的代码。
你可以把它想象成 TypeScript 的“眼镜”,没有它,TS 编译器就会“看不清楚”你的项目到底该怎么理解、怎么校验。
- 影响代码能不能被正确编译
如果我们用了某些新语法(比如 optional chaining
、import type
),却没有在 tsconfig 里声明 "target": "ESNext"
,那 TypeScript 就会报错:看不懂!
- 影响编辑器的智能提示
如果我们用了路径别名 @/utils/index.ts
,但没有配置:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
那 VS Code 就会一直红线报错:“找不到模块”。
- 影响类型检查的严格程度
比如 "strict": true
会让我们代码写得更规范,少写 any
,避免“空值未处理”这类隐患;而关闭了就“宽松模式”,你可能一不小心就放过了 bug。
- 影响团队代码规范一致性
当多个成员一起开发时,统一 tsconfig.json
能让大家都用一样的校验标准,避免“我这边没问题你那边报错”的尴尬。
tsconfig.json
文件的一个典型配置如下:
{
"compilerOptions": {
// ECMAScript 的目标版本(决定生成的代码是 ES5 还是 ES6 等)
"target": "ESNext",
// 模块系统,这里用 ESNext 是为了支持 Vite 的现代打包机制
"module": "ESNext",
// 模块解析策略,Node 方式支持从 node_modules 中解析模块
"moduleResolution": "Node",
// 启用源映射,便于调试(ts -> js 映射)
"sourceMap": true,
// 启用 JSX 支持(如用于 Vue 的 TSX/JSX 语法)
"jsx": "preserve",
// 编译结果是否使用 ES 模块的导出语法(import/export)
"esModuleInterop": true,
// 允许默认导入非 ESModule 模块(兼容 CommonJS)
"allowSyntheticDefaultImports": true,
// 生成声明文件(一般用于库开发,可选)
"declaration": false,
// 设置项目根路径,配合 paths 使用
"baseUrl": ".",
// 路径别名配置,@ 代表 src 目录,方便引入模块
"paths": {
"@/*": ["src/*"]
},
// 开启严格模式(类型检查更严格,建议开启)
"strict": true,
// 不检查未使用的局部变量
"noUnusedLocals": true,
// 不检查未使用的函数参数
"noUnusedParameters": true,
// 禁止隐式的 any 类型(没有类型声明时报错)
"noImplicitAny": true,
// 禁止将 this 用在不合法的位置
"noImplicitThis": true,
// 允许在 JS 文件中使用 TypeScript(一般不建议)
"allowJs": false,
// 允许编译 JS 文件(如需使用 legacy 代码可开启)
"checkJs": false,
// 指定输出目录(Vite 会忽略它,一般不用)
"outDir": "./dist",
// 开启增量编译(提升大型项目编译效率)
"incremental": true,
// 类型定义自动引入的库(默认会包含 dom、esnext 等)
"lib": ["ESNext", "DOM"]
},
// 指定编译包含的文件(推荐指定为 src)
"include": ["src/**/*"],
// 排除 node_modules 和构建输出目录
"exclude": ["node_modules", "dist"]
}
Vite 项目中,一般 tsconfig.json
会被自动加载,所以只需要按需修改上述配置即可。
tsconfig.node.json 又是干嘛的?
tsconfig.node.json
并不是 TypeScript 官方强制的命名,而是一种 社区约定俗成 的分离配置方式。它用于配置运行在 Node.js 环境下的 TypeScript 代码,例如:
vite.config.ts
(构建配置)scripts/*.ts
(一些本地开发脚本)server/*.ts
(如果你有 Node 后端)
tsconfig.node.json的一大作用就是针对业务代码和项目中的node代码做区分,划分职责。
如果不写tsconfig.node.json,会出现以下问题:
比如你写了一个脚本:scripts/generate-sitemap.ts
,其中用到了 fs
、path
、url
等 Node 原生模块,但主 tsconfig.json
是为浏览器服务的:
- 设置了
"module": "ESNext"
,TypeScript 编译器可能不会生成符合 Node 环境要求的代码。 - 缺少
moduleResolution: "node"
会导致路径解析失败。
常见配置内容:
{
"compilerOptions": {
// 使用最新的 ECMAScript 特性
"target": "ESNext",
// 使用 CommonJS 模块系统,兼容 Node.js(也可根据项目设置为 ESNext)
"module": "CommonJS",
// 模块解析方式设置为 Node(支持 node_modules 和路径别名)
"moduleResolution": "Node",
// 启用严格模式,增加类型安全
"strict": true,
// 允许默认导入非 ESModule 的模块(如 import fs from 'fs')
"esModuleInterop": true,
// 支持 import type 等语法
"allowSyntheticDefaultImports": true,
// 添加 Node.js 类型定义
"types": ["node"],
// 源码映射(可选)
"sourceMap": true,
// 启用增量编译(加快重编译速度)
"incremental": true
},
// 指定哪些文件纳入编译,通常包含 Node 环境下的脚本或配置文件
"include": [
"vite.config.ts",
"scripts/**/*",
"build/**/*"
],
// 排除构建产物和依赖
"exclude": [
"node_modules",
"dist"
]
}
两者区别
对比点 | tsconfig.json | tsconfig.node.json |
---|---|---|
目标环境 | 浏览器(前端代码) | Node.js(构建脚本、配置文件) |
类型声明支持 | 浏览器相关,通常不包含 node 类型 | 显式包含 node 类型 |
使用场景 | 项目源码、页面组件、前端逻辑 | vite.config.ts、开发工具脚本、构建相关逻辑 |
典型依赖项 | Vue 类型(如 vue , @vue/runtime-dom ) | Node 类型(如 fs , path ) |
是否必须存在 | 是,TypeScript 项目基本都要有 | 否,但推荐拆分使用以清晰职责 |
是否引用主配置 | 通常是主配置 | 可通过 tsconfig.json 的 references 引用它 |
来源:juejin.cn/post/7500130421608579112
京东鸿蒙上线前瞻——使用 Taro 打造高性能原生应用
背景
2024 年 1 月,京东正式启动鸿蒙原生应用开发,基于 HarmonyOS NEXT 的全场景、原生智能、原生安全等优势特性,为消费者打造更流畅、更智能、更安全的购物体验。同年 6 月,京东鸿蒙原生应用尝鲜版上架华为应用市场,计划 9 月完成正式版的上架。
早在 2020 年,京东与华为就签署了战略合作协议,不断加大技术投入探索 HarmonyOS 的创新特性。作为华为鸿蒙生态的首批头部合作伙伴,在适配鸿蒙操作系统的过程中,京东与华为一直保持着密切的技术沟通与共创,双方共同攻坚行业适配难点,并推动多端统一开发解决方案 Taro 在业界率先实现对鸿蒙 ArkUI 的原生开发支持。
本文将阐述京东鸿蒙原生应用在开发时所采用的技术方案、技术特点、性能表现以及未来的优化计划。通过介绍选择 Taro 作为京东鸿蒙原生应用的开发框架的原因,分析 Taro 在支持 Web 范式开发、快速迁移存量项目、渲染性能优化、高阶功能支持以及混合开发模式等方面的优势。
技术方案
京东在开发鸿蒙原生应用的过程中,需要考虑如何在有限的时间内高效完成项目,同时兼顾应用的性能与用户体验。为了达成这一目标,选择合适的技术方案至关重要。
在技术选型方面,开发一个鸿蒙原生应用,一般会有两种选择:
- 使用原生 ArkTS 进行鸿蒙开发
- 使用跨端框架进行鸿蒙开发
使用原生 ArkTS 进行鸿蒙开发,面临着开发周期冗长、维护多端多套应用代码成本高昂的挑战。在交付时间紧、任务重的情况下,京东果断选择跨端框架来开发鸿蒙原生应用,以期在有限的时间内高效完成项目。
作为在业界具备代表性的开源跨端框架之一,Taro 是由京东凹凸实验室团队开发的一款开放式跨端跨框架解决方案,它支持开发者使用一套代码,实现在 H5、小程序以及鸿蒙等多个平台上的运行。
通过 Taro 提供的编译能力,开发者可以将整个 Taro 项目轻松地转换为一个独立的鸿蒙应用,无需额外的开发工作。
另外,Taro 也支持将项目里的部分页面以模块化的形式打包进原生的鸿蒙应用中,京东鸿蒙原生应用便是使用这种模式进行开发的。
京东鸿蒙原生应用的基础基建能力如路由、定位、权限等能力由京东零售 mpass 团队来提供,而原生页面的渲染以及与基建能力的桥接则由 Taro 来负责,业务方只需要将写好的 Taro 项目通过执行相应的命令,就可以将项目以模块的形式一键打包到鸿蒙应用中,最终在应用内渲染出对应的原生页面,整个过程简单高效。
技术特点
Taro 作为一款开放式跨端跨框架解决方案,在支持开发者一套代码多端运行的同时,也为开发鸿蒙原生应用提供了诸多便利。在权衡多方因素后,我们最终选择了 Taro 作为开发鸿蒙原生应用的技术方案,总的来说,使用 Taro 来开发鸿蒙原生应用会有下面几点优势:
支持开发者使用 Web 范式来开发鸿蒙原生应用
与鸿蒙原生开发相比,使用 Taro 进行开发的最大优点在于 Taro 支持开发者使用前端 Web 范式来开发鸿蒙原生应用,基于这一特点,我们对大部分 CSS 能力进行了适配:
- 支持常见的 CSS 样式和布局,支持 flex、伪类和伪元素
- 支持常见的 CSS 定位,绝对定位、fixed 定位
- 支持常见的 CSS 选择器和媒体查询
- 支持常见的 CSS 单位,比如 vh、vw 以及计算属性 calc
- 支持 CSS 变量以及安全区域等预定义变量
在编译流程上,我们采用了 Rust 编写的 LightningCSS,极大地提升了 CSS 文件的编译和解析速度。
(图片来自 LightningCSS 官网)
在运行时上,我们参考了 WebKit 浏览器内核的处理流程,对于 CSS 规则的匹配和标脏进行了架构上的升级,大幅提升了 CSS 应用和更新的性能。
支持存量 Taro 项目的快速迁移
将现有业务适配到一个全新的端侧平台,无疑需要投入大量的人力物力。而 Taro 框架的主要优势,正是能够有效解决这种跨端场景下的项目迁移难题。通过 Taro,我们可以以极低的成本,在保证高度还原和高性能的前提下,快速地将现有的 Taro 项目迁移到鸿蒙系统上。
渲染性能比肩原生开发
在 Taro 转换鸿蒙原生页面的技术实现上,我们摒弃了之前使用 ArkTS 原生组件递归渲染节点树的方案,将更多的运行时逻辑如组件、动效、测算和布局等逻辑下沉到了 C++ 层,极大地提升了页面的渲染性能。
另外,我们对于 Taro 项目中 CSS 样式的处理架构进行了一次整体的重构和升级,并引入布局引擎Yoga,将页面的测量和布局放在 Taro 侧进行实现,基于这些优化,实现一套高效的渲染任务管线,使得 Taro 开发的鸿蒙页面在性能上足以和鸿蒙 ArkTS 原生页面比肩。
支持虚拟列表和节点复用等高阶功能
长列表渲染是应用开发普遍会遇到的场景,在商品列表、订单列表、消息列表等需要无限滚动的组件和页面中广泛存在,这些场景如果不进行特殊的处理,只是单纯对数据进行渲染和更新,在数据量非常大的情况下,可能会引发严重的性能问题,导致视图在一段时间内无法响应用户操作。
在这个背景下,Taro 在鸿蒙端提供了长列表类型组件(WaterFlow & List) ,并对长列表类型组件进行了优化,提供了懒加载、预加载和节点复用等功能,有效地解决大数据量下的性能问题,提高应用的流畅度和用户体验。
(图片来自 HarmonyOS 官网)
支持原生混合开发等多种开发模式
Taro 的组件和 API 是以小程序作为基准来进行设计的,因此在实际的鸿蒙应用开发过程中,会出现所需的组件和 API 在 Taro 中不存在的情况,因为针对这种情况,Taro 提供了原生混合开发的能力,支持将原生页面或者原生组件混合编译到 Taro 鸿蒙项目中,支持 Taro 组件和鸿蒙原生组件在页面上的混合使用。
性能表现
京东鸿蒙原生应用性能数据
经过对 Taro 的屡次优化和打磨,使得京东鸿蒙原生应用取得了优秀的性能表现,最终首页的渲染耗时 1062ms,相比于之前的 ArkTS 版本,性能提升了 23.9% ;商详的渲染耗时 560 ms,相比于之前的 ArkTS 版本,性能提升 74.2% 。
值得注意的是商详页性能提升显著,经过分析发现商详楼层众多,CSS 样式也复杂多样,因此在 ArkTS 版本中,在 CSS 的解析和属性应用阶段占用了过多的时间,在 CAPI 版本进行了CSSOM 模块的架构升级后,带来了明显的性能提升。
基于 Taro 开发的页面,在华为性能工厂的专业测试下,大部分都以优异的成绩通过了性能验收,充分证明了 Taro 在鸿蒙端的高性能表现。
总结和未来展望
Taro 目前已经成为一个全业务域的跨端开发解决方案,实现 Web 类(如小程序、Hybrid)和原生类(iOS、Android、鸿蒙)的一体化开发,在高性能的鸿蒙适配方案的加持下,业务能快速拓展到新兴的鸿蒙系统中去,可以极大满足业务集约化开发的需求。
未来计划
后续,Taro 还会持续在性能上进行优化,以更好地适配鸿蒙系统:
- 将开发者的 JS 业务代码和应用框架层的 JS 代码与主线程的 UI 渲染逻辑分离,另起一条 JavaScript 线程,执行这些 JS 代码,避免上层业务逻辑堵塞主线程运行,防止页面出现卡顿、丢帧的现象。
- 实现视图节点拍平,将不影响布局的视图节点进行整合,减少实际绘制上屏的页面组件节点数量,提升页面的渲染性能。
(图片来自 React Native 官网)
- 实现原生性能级别的动态更新能力,支持开发者在不重新编译和发布应用的情况下,动态更新应用中的页面和功能。
总结
京东鸿蒙原生应用是 Taro 打响在鸿蒙端侧适配的第一枪,证明了 Taro 方案适配鸿蒙原生应用的可行性。这标志着 Taro 在多端统一开发上的新突破,意味着 Taro 将为更多的企业和开发者提供优秀的跨端解决方案,使开发者能够以更高的效率开发出适配鸿蒙系统的高性能应用。
来源:juejin.cn/post/7412486655862571034
harmony-安装app的脚本
概述
现在鸿蒙手机安装harmony.app包有很大限制,他并不能像Android那样直接在手机上安装apk,也不像IOS可以传多个ipa,AppGallery Connect只能同时上传3个包,也就是说QA只能同时测3个不同的包,这样大大限制了开发效率和测试效率,大致解决方案
- 给QA开通git权限,下载 DevEco Studio ,让QA直接run对应的分支的包
- 直接让QA拿着手机让研发给跑包
- 上传AppGallery Connect,华为审核通过之后,直接扫码安装,缺点是:只能同时测试3个,还需要审核
但是上面这三种方案都对研发和QA不太友好,所以悄悄的写了个安装脚本,让QA直接运行即可,
无需配置hdc环境变量
无需下载DevEco Studio
像Android 使用 adb 安装apk一样
脚本下载hdc文件只有1.7M
如果需要手动签名额外需要hap-sign-tool.jar和java两个文件【不建议在脚本中手动签名,请在打包服务器中,比如jenkins】
,当然了测试签名还是可以放在脚本中的
编写 Shell 脚本,先上图
成功
错误
首先在 DevEco Studio 运行,看看执行了哪些命令
使用 DevEco Studio 创建一个工程,然后一个 basic的hsp 和 login的har,让entry依赖这两个mudule,
build task in 1 s 364 ms
Launching com.nzy.installapp
$ hdc shell aa force-stop com.nzy.installapp
$ hdc shell mkdir data/local/tmp/5588cff7d2344a0db70a270bb22aa455
$ hdc file send /Users/xxx/DevEcoStudioProjects/InstallApp/feature/login/build/default/outputs/default/login-default-signed.hsp "data/local/tmp/5588cff7d2344a0db70a270bb22aa455" in 54 ms
$ hdc file send /Users/xxx/DevEcoStudioProjects/InstallApp/entry/build/default/outputs/default/entry-default-signed.hap "data/local/tmp/5588cff7d2344a0db70a270bb22aa455" in 34 ms
$ hdc shell bm install -p data/local/tmp/5588cff7d2344a0db70a270bb22aa455 in 217 ms
$ hdc shell rm -rf data/local/tmp/5588cff7d2344a0db70a270bb22aa455
$ hdc shell aa start -a EntryAbility -b com.nzy.installapp in 148 ms
Launch com.nzy.installapp success in 1 s 145 ms
上面的命令的意思是
- $ hdc shell aa force-stop [bundleName] 强制停止 bundleName 的进程
- $ hdc shell mkdir data/local/tmp/5588cff7d2344a0db70a270bb22aa455 给手机端创建临时目录
- $ hdc file send hsp 临时目录:把所有的hsp 发送到临时目录
- $ hdc file send hap 临时目录:把hap 发送到临时目录
- hdc shell bm install -p 临时目录:安装临时目录中的所有hsp和hap
- $ hdc shell rm -rf 临时目录:删除手机端的临时目录
- $ hdc shell aa start -a EntryAbility -b [bundleName]:启动bundleName的EntryAbility的页面
大家或许有疑惑,明明创建了 HAR,但是本次安装没有 HAR,因为 HAR 会被编译打包到所有依赖该模块的 HAP 和 HSP
咱们可以根据上面的流程大致写一下
脚本方案
- 检测hdc文件是否存在,不存在使用cur下载
- 检测是否连接手机,并且只有一个手机
- 检测传入app的路径是否存是以.app结尾,并且文件存在
- 创建手机端临时目录
- 解压.app到电脑端,复制里面的所有hsp和hap到 临时目录,
如果需要手动签名可以在这一步去签名
- 安装临时目录的所有文件
- 删除手机临时目录以及电脑端解压app的目录
hdc文件
我们可以从华为官网下载 Command Line Tools,竟然有2.3G
,这让脚本下载到猴牛马月,下载下来hdc在command-line-tools/sdk/default/openharmony/toolchains
当然了,我们可以精简文件,我发现只需要 hdc和libusb_shared.dylib 两个文件,所以直接把这两个文件打包的一个zip放在了gitee上(大约1.7M
),放到cdn上供我们的脚本去下载,这样我们可以使用cur去下载,当然这个最好放在自己公司的cdn上,方便下载
首先创建install.sh的脚本
首先定义几个常量
- hdcZip:下载下来的zip名
- hdcTool:解压出来放到本文件夹
- hdcPath:使用hdc命令的path
- bundleName:自己的bundleName
- entryAbility:要打开的Ability
# 下载下来的文件
hdcZip="tools.zip"
# 解压的文件夹 ,解压默认是和 install.sh 脚本在同一个目录
hdcTool="tools"
# hdc文件路径"
hdcPath="tools/hdc"
#包名
bundleName="com.nzy.installapp"
# 要打开的Ability
entryAbility="EntryAbility"
定义打印
- printInfo:打印正常信息
- printError:打印错误信息,并且会调用exit 1
function printInfo() {
# ANSI 转义码颜色 绿色
local message=$1
printf "\e[32m%s\e[0m\n" "$message" # Info
}
function printError() {
# ANSI 转义码颜色 红色
local message=$1
printf "\e[31m%s\e[0m\n" "错误:$message"
# 退出程序
exit 1
}
检查和下载hdc
if [ ! -f "${hdcPath}" ]; then
# 不存在开始下载
printInfo "首次需要下载hdc工具,2M"
URL="https://gitee.com/zhiyangnie/install-shell/raw/master/tools.zip"
# 下载到当前目录的 tools.zip
# 使用 curl 下载
curl -o "$hdcZip" "$URL"
if [ $? -eq 0 ]; then
printInfo "下载成功,准备解压${hdcZip}..."
# 解压ZIP文件
unzip -o "$hdcZip" -d "${hdcTool}"
# 检查解压是否成功
if [ $? -eq 0 ]; then
printInfo "${hdcZip}解压成功"
# 删除zip
rm "$hdcZip"
else
printError "${hdcZip} 解压失败,请手动解压"
fi
else
printError "下载失败,请检查网络"
fi
fi
判断hdc是否可用以及连接手机数量
# 判断是否连接手机且仅有一个手机
devicesList=$(${hdcPath} list targets)
# 判断是否hdc 可用
if [ -z "$devicesList" ]; then
# 开始下载zip
print_error "hdc 不可用 ,请检查本目录是否存在 ${hdcPath}"
fi
# 判断是否连接手机,如果有 [Empty] 表明 一个手机也没连接
if [[ "$devicesList" == *"[Empty]"* ]]; then
printError "未识别到手机,请连接手机,打开开发者选项和USB调试"
fi
# 判断连接手机的个数
deviceCount=$(${hdcPath} list targets | wc -l)
if [ "$deviceCount" -ne 1 ]; then
printError "错误:连接的手机个数是 ${deviceCount} 个,请连接一个手机"
fi
printInfo "连接到手机,且仅有一个手机 ${devicesList}"
检测传入app的路径是否存是以.app结尾,并且文件存在
# 传过来的参数是 ,获取输入的 app 文件
appFile="$1"
# 判读传过来的路径文件是否以.app 结尾
if [[ ! "${appFile}" =~ .app ]]; then
printError "请传入正确的包路径,文件要 .app 结尾"
fi
# 判断文件是否存在
if [ ! -e "$appFile" ]; then
printError "不存在改文件 $appFile 。请确认"
fi
开始安装
#------------------------------开始安装----------------------------------
# 开始安装
printInfo "开始安装应用, ${bundleName}"
# 1.先kill当前app的进程
$hdcPath shell aa force-stop "$bundleName"
# hdc shell mkdir data/local/tmp/c3af89b189d2480395ce746621ce6385
# 2.创建随机文件夹
randomHex=$(xxd -l 16 -p /dev/urandom)
randomFile="data/local/tmp/$randomHex"
mkDirSuccess=$($hdcPath shell mkdir "$randomFile" 2>&1)
if [ -n "$mkDirSuccess" ]; then
printError "手机中:随机创建文件夹 ${randomFile} 失败 , $mkDirSuccess"
else
printInfo "手机中:创建随机文件夹 ${randomFile} 成功"
fi
# 3.解压.app中
# 在本地创建 tmp 临时文件夹
tmp="tmp"
# 存在先删除
if [ -d "${tmp}" ]; then
rm -rf "$tmp"
fi
mkdir -p "$tmp"
# 解压.app ,使用 unUse 主要是 不想打印那么多的解压日志
unUse=$(unzip -o "$appFile" -d "$tmp")
if [ $? -eq 0 ]; then
printInfo "解压app成功"
else
printError "解压app失败,请传入正确的app。$appFile , "
fi
printInfo "遍历解压发送到 手机的$randomFile"
# 4.遍历 tmp 文件夹中的文件发送到 randomFile 中
for item in "${tmp}"/*; do
if [ -f "$item" ]; then
# 发送 以 .hsp 或 .hap 结尾。
if [[ "$item" == *.hsp || "$item" == *.hap ]]; then
$hdcPath file send "$item" "$randomFile"
fi
fi
done
printInfo "成功发送到 手机的$randomFile "
# 5. 使用 install
# hdc shell bm install -p data/local/tmp/c3af89b189d2480395ce746621ce6385
installStatus=$($hdcPath shell bm install -p "$randomFile" 2>&1)
if [[ "$installStatus" == *"successfully"* ]]; then
printInfo "┌────────────────────────────────────────────────────────"
printInfo "│ ✅ 安装成功 "
printInfo "└────────────────────────────────────────────────────────"
${hdcPath} shell aa start -a "${entryAbility}" -b "$bundleName"
else
printf "\e[31m%s\e[0m\n" "┌────────────────────────────────────────────────────────"
printf "\e[31m%s\e[0m\n" "│❌ 安装错误"
echo "$installStatus" | while IFS= read -r line; do
printf "\e[31m%s\e[0m\n" "│${line}"
done
printf "\e[31m%s\e[0m\n" "│错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/bm-tool-V5"
printf "\e[31m%s\e[0m\n" "└────────────────────────────────────────────────────────"
fi
删除文件
# 删除 手机端的 $randomFile
${hdcPath} shell rm -rf "$randomFile"
# 删除本地的tmp文件夹
rm -rf "$tmp"
使用
进入install.sh父目录,执行
./install.sh [包的路径]
注意点
如果自己编写的的时候,如果执行 ./install.sh 的时候 报错 zsh: permission denied: ./install.sh,证明 这个shell脚本没有运行权限,可以使用ls -l install.sh 检测 权限,如果是
-rw-r--r-- 是没有权限的,然后执行 chmod +x install.sh ,就会加上权限,然后在执行ls -l install.sh ,可以看到-rwxr-xr-x,然后就可以 执行 ./install.sh了
地址
真机上安装需要正式手动签名
当我们签名之后的app,虽然这个app是签名的,但是里面的hap和hsp是没有签名的,所以我们要用脚本把hap和shp都要进行手动签名。一般是打包工具比如Jenkins 来做这个工作,因为正式签名不会让研发拿到,更不会让QA拿到。
需要手动签名,参考:
签名
appCertFile="sign/install.cer"
profileFile="sign/install.p7b"
keystoreFile="sign/install.p12"
keyAlias="zhiyang"
keyPwd="a123456A"
keystorePwd="a123456A"
java -jar tools/lib/hap-sign-tool.jar sign-app -keyAlias "${keyAlias}" -signAlg "SHA256withECDSA" -mode "localSign" -appCertFile "${appCertFile}" -profileFile "${profileFile}" -inFile "${inputFile}" -keystoreFile "${keystoreFile}" -outFile "${outputFile}" -keyPwd "${keyPwd}" -keystorePwd "${keystorePwd}"
- Store file:选择密钥库文件,文件后缀为.p12,该文件为生成密钥和证书请求文件中生成的.p12文件。
- Store password:输入密钥库密码,该密码与生成密钥和证书请求文件中填写的密钥库密码保持一致。
- Key alias:输入密钥的别名信息,与生成密钥和证书请求文件中填写的别名保持一致。
- Key password:输入密钥的密码,与生成密钥和证书请求文件中填写的Store Password保持一致。
- Sign alg:签名算法,固定为SHA256withECDSA。
- Profile file:选择申请调试证书和调试Profile文件中生成的Profile文件,文件后缀为.p7b。
- Certpath file:选择申请调试证书和调试Profile文件中生成的数字证书文件,文件后缀为.cer。
对hsp和hap签名
手动签名需要hap-sign-tool.jar,在command-line-tools/sdk/default/openharmony/toolchains/libs/hap-sign-tool.jar并且需要java文件,也都放到项目中了
在脚本中解压app之后,发送到手机之前,对所有的hsp和hap签名
代码如下
signHapAndHsp(){
appCertFile="sign/install.cer"
profileFile="sign/install.p7b"
keystoreFile="sign/install.p12"
keyAlias="zhiyang"
keyPwd="a123456A"
keystorePwd="a123456A"
javaFile="lib/java"
hapSignToolFile="lib/hap-sign-tool.jar"
local item=$1
#遍历文件夹,拿到所有的hsp和hap去签名
for item in "${tmp}"/*; do
if [ -f "$item" ]; then
# 发送 以 .hsp 或 .hap 结尾。
if [[ "$item" == *.hsp || "$item" == *.hap ]]; then
# 开始签名
local inputFile="${item}"
outputFile=""
if [[ "$inputFile" == *.hap ]]; then
outputFile="${inputFile%.hap}-sign.hap"
else
outputFile="${inputFile%.hsp}-sign.hsp"
fi
signStatus=$(java -jar tools/lib/hap-sign-tool.jar sign-app -keyAlias "${keyAlias}" -signAlg "SHA256withECDSA" -mode "localSign" -appCertFile "${appCertFile}" -profileFile "${profileFile}" -inFile "${inputFile}" -keystoreFile "${keystoreFile}" -outFile "${outputFile}" -keyPwd "${keyPwd}" -keystorePwd "${keystorePwd}" -signCode "1" 2>&1)
signStatus=$(${javaFile} -jar "${hapSignToolFile}" sign-app -keyAlias "${keyAlias}" -signAlg "SHA256withECDSA" -mode "localSign" -appCertFile "${appCertFile}" -profileFile "${profileFile}" -inFile "${inputFile}" -keystoreFile "${keystoreFile}" -outFile "${outputFile}" -keyPwd "${keyPwd}" -keystorePwd "${keystorePwd}" -signCode "1" 2>&1)
if [[ "$signStatus" == *"failed"* || $signStatus == *"No such file or directory"* ]]; then
printError "签名失败,${signStatus}"
else
printInfo "签名成功,${inputFile} , ${outputFile} , ${signStatus}"
#删除以前未签名的
rm -f "$inputFile"
fi
fi
fi
done
printInfo "签名完成,${signStatus}"
}
注意点
如果报错是
code:9568322
error: signature verification failed due to not trusted app source.表明你的真机需要在添加你的设备,参考注册调试设备
这是真机的效果
在shell文件夹运行 ./install.sh ./InstallApp-default-signed.app
使用我demo,如果要写手动签名脚本,需要更换sign文件夹,自己去申请签名,并且要更换bundleName
,因为你的设备并没有在我的华为Profile里面添加
来源:juejin.cn/post/7438456086308651045
横扫鸿蒙弹窗乱象,SmartDialog出世
前言
但凡用过鸿蒙原生弹窗的小伙伴,就能体会到它们是有多么的难用和奇葩,什么AlertDialog,CustomDialog,SubWindow,bindXxx,只要大家用心去体验,就能发现他们有很多离谱的设计和限制,时常就是一边用,一边骂骂咧咧的吐槽
实属无奈,就把鸿蒙版的SmartDialog写出来了
flutter自带的dialog是可以应对日常场景,例如:简单的打开一个弹窗,非UI模块使用,跨页面交互之类;flutter_smart_dialog 是补齐了大多数的业务场景和一些强大的特殊能力,flutter_smart_dialog 对于flutter而言,日常场景是锦上添花,特殊场景是雪中送炭
但是 ohos_smart_dialog 对于鸿蒙而言,日常场景就是雪中送炭!单单一个使用方式而言,就是吊打鸿蒙的CustomDialog,CustomDialog的各种限制和使用方式,我不想再去提及和吐槽了
有时候,简洁的使用,才是最大的魅力
鸿蒙版的SmartDialog有什么优势?
- 单次初始化后即可使用,无需多处配置相关Component
- 优雅,极简的用法
- 非UI区域内使用,自定义Component
- 返回事件处理,优化的跨页面交互
- 多弹窗能力,多位置弹窗:上下左右中间
- 定位弹窗:自动定位目标Component
- 极简用法的loading弹窗
- 等等......
目前 flutter_smart_dialog 的代码量16w+,完整复刻其功能,工作量非常大,目前只能逐步实现一些基础能力,由于鸿蒙api的设计和相关限制,用法和相关初始化都有一定程度的妥协
鸿蒙版本的SmartDialog,功能会逐步和 flutter_smart_dialog 对齐(长期),api会尽量保持一致
效果
- Tablet 模拟器目前有些问题,会导致动画闪烁,请忽略;注:真机动画丝滑流畅,无任何问题
极简用法
// dialog
SmartDialog.show({
builder: dialogArgs,
builderArgs: Math.random(),
})
@Builder
function dialogArgs(args: number) {
Text(args.toString()).padding(50).backgroundColor(Color.White)
}
// loading
SmartDialog.showLoading()
安装
ohpm install ohos_smart_dialog
配置
下述的配置项,可能会有一点多,但,这也是为了极致的体验;同时也是无奈之举,相关配置难以在内部去闭环处理,只能在外部去配置
这些配置,只需要配置一次,后续无需关心
完成下述的配置后,你将可以在任何地方使用弹窗,没有任何限制
初始化
- 注:内部已使用无感路由注册,外部无需手动处理
@Entry
@Component
struct Index {
navPathStack: NavPathStack = new NavPathStack()
build() {
Stack() {
Navigation(this.navPathStack) {
MainPage()
}
.mode(NavigationMode.Stack)
.hideTitleBar(true)
.navDestination(pageMap)
// here dialog init
OhosSmartDialog()
}.height('100%').width('100%')
}
}
返回事件监听
别问我为啥返回事件的监听,处理的这么不优雅,鸿蒙里面没找全局返回事件监听,我也没辙。。。
- 如果你无需处理返回事件,可以使用下述写法
// Entry页面处理
@Entry
@Component
struct Index {
onBackPress(): boolean | void {
return SmartDialog.onBackPressed()()
}
}
// 路由子页面
struct JumpPage {
build() {
NavDestination() {
// ....
}
.onBackPressed(SmartDialog.onBackPressed())
}
}
- 如果你需要处理返回事件,在SmartDialog.onBackPressed()中传入你的方法即可
// Entry页面处理
@Entry
@Component
struct Index {
onBackPress(): boolean | void {
return SmartDialog.onBackPressed(this.onCustomBackPress)()
}
onCustomBackPress(): boolean {
return false
}
}
// 路由子页面
@Component
struct JumpPage {
build() {
NavDestination() {
// ...
}
.onBackPressed(SmartDialog.onBackPressed(this.onCustomBackPress))
}
onCustomBackPress(): boolean {
return false
}
}
适配暗黑模式
- 为了极致的体验,深色模式切换时,打开态弹窗也应刷新为对应模式的样式,故需要进行下述配置
export default class EntryAbility extends UIAbility {
onConfigurationUpdate(newConfig: Configuration): void {
OhosSmartDialog.onConfigurationUpdate(newConfig)
}
}
SmartConfig
- 支持全局配置弹窗的默认属性
function init() {
// show
SmartDialog.config.custom.maskColor = "#75000000"
SmartDialog.config.custom.alignment = Alignment.Center
// showAttach
SmartDialog.config.attach.attachAlignmentType = SmartAttachAlignmentType.center
}
- 检查弹窗是否存在
// 检查当前是否有CustomDialog,AttachDialog或LoadingDialog处于打开状态
let isExist = SmartDialog.checkExist()
// 检查当前是否有AttachDialog处于打开状态
let isExist = SmartDialog.checkExist({ dialogTypes: [SmartAllDialogType.attach] })
// 检查当前是否有tag为“xxx”的dialog处于打开状态
let isExist = SmartDialog.checkExist({ tag: "xxx" })
配置全局默认样式
- ShowLoading 自定样式十分简单
SmartDialog.showLoading({ builder: customLoading })
但是对于大家来说,肯定是想用 SmartDialog.showLoading()
这种简单写法,所以支持自定义全局默认样式
- 需要在 OhosSmartDialog 上配置自定义的全局默认样式
@Entry
@Component
struct Index {
build() {
Stack() {
OhosSmartDialog({
// custom global loading
loadingBuilder: customLoading,
})
}.height('100%').width('100%')
}
}
@Builder
export function customLoading(args: ESObject) {
LoadingProgress().width(80).height(80).color(Color.White)
}
- 配置完你的自定样式后,使用下述代码,就会显示你的 loading 样式
SmartDialog.showLoading()
// 支持入参,可以在特殊场景下灵活配置
SSmartDialog.showLoading({ builderArgs: 1 })
CustomDialog
- 下方会共用的方法
export function randomColor(): string {
const letters: string = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
export function delay(ms?: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
传参弹窗
export function customUseArgs() {
SmartDialog.show({
builder: dialogArgs,
// 支持任何类型
builderArgs: Math.random(),
})
}
@Builder
function dialogArgs(args: number) {
Text(`${args}`).fontColor(Color.White).padding(50)
.borderRadius(12).backgroundColor(randomColor())
}
多位置弹窗
export async function customLocation() {
const animationTime = 1000
SmartDialog.show({
builder: dialogLocationHorizontal,
alignment: Alignment.Start,
})
await delay(animationTime)
SmartDialog.show({
builder: dialogLocationVertical,
alignment: Alignment.Top,
})
}
@Builder
function dialogLocationVertical() {
Text("location")
.width("100%")
.height("20%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}
@Builder
function dialogLocationHorizontal() {
Text("location")
.width("30%")
.height("100%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}
跨页面交互
- 正常使用,无需设置什么参数
export function customJumpPage() {
SmartDialog.show({
builder: dialogJumpPage,
})
}
@Builder
function dialogJumpPage() {
Text("JumPage")
.fontSize(30)
.padding(50)
.borderRadius(12)
.fontColor(Color.White)
.backgroundColor(randomColor())
.onClick(() => {
// 跳转页面
})
}
关闭指定弹窗
export async function customTag() {
const animationTime = 1000
SmartDialog.show({
builder: dialogTagA,
alignment: Alignment.Start,
tag: "A",
})
await delay(animationTime)
SmartDialog.show({
builder: dialogTagB,
alignment: Alignment.Top,
tag: "B",
})
}
@Builder
function dialogTagA() {
Text("A")
.width("20%")
.height("100%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}
@Builder
function dialogTagB() {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(["closA", "closeSelf"], (item: string, index: number) => {
Button(item)
.backgroundColor("#4169E1")
.margin(10)
.onClick(() => {
if (index === 0) {
SmartDialog.dismiss({ tag: "A" })
} else if (index === 1) {
SmartDialog.dismiss({ tag: "B" })
}
})
})
}.backgroundColor(Color.White).width(350).margin({ left: 30, right: 30 }).padding(10).borderRadius(10)
}
自定义遮罩
export function customMask() {
SmartDialog.show({
builder: dialogShowDialog,
maskBuilder: dialogCustomMask,
})
}
@Builder
function dialogCustomMask() {
Stack().width("100%").height("100%").backgroundColor(randomColor()).opacity(0.6)
}
@Builder
function dialogShowDialog() {
Text("showDialog")
.fontSize(30)
.padding(50)
.fontColor(Color.White)
.borderRadius(12)
.backgroundColor(randomColor())
.onClick(() => customMask())
}
AttachDialog
默认定位
export function attachEasy() {
SmartDialog.show({
builder: dialog
})
}
@Builder
function dialog() {
Stack() {
Text("Attach")
.backgroundColor(randomColor())
.padding(20)
.fontColor(Color.White)
.borderRadius(5)
.onClick(() => {
SmartDialog.showAttach({
targetId: "Attach",
builder: targetLocationDialog,
})
})
.id("Attach")
}
.borderRadius(12)
.padding(50)
.backgroundColor(Color.White)
}
@Builder
function targetLocationDialog() {
Text("targetIdDialog")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.borderRadius(12)
.backgroundColor(randomColor())
}
多方向定位
export function attachLocation() {
SmartDialog.show({
builder: dialog
})
}
class AttachLocation {
title: string = ""
alignment?: Alignment
}
const locationList: Array<AttachLocation> = [
{ title: "TopStart", alignment: Alignment.TopStart },
{ title: "Top", alignment: Alignment.Top },
{ title: "TopEnd", alignment: Alignment.TopEnd },
{ title: "Start", alignment: Alignment.Start },
{ title: "Center", alignment: Alignment.Center },
{ title: "End", alignment: Alignment.End },
{ title: "BottomStart", alignment: Alignment.BottomStart },
{ title: "Bottom", alignment: Alignment.Bottom },
{ title: "BottomEnd", alignment: Alignment.BottomEnd },
]
@Builder
function dialog() {
Column() {
Grid() {
ForEach(locationList, (item: AttachLocation) => {
GridItem() {
buildButton(item.title, () => {
SmartDialog.showAttach({
targetId: item.title,
alignment: item.alignment,
maskColor: Color.Transparent,
builder: targetLocationDialog
})
})
}
})
}.columnsTemplate('1fr 1fr 1fr').height(220)
buildButton("allOpen", async () => {
for (let index = 0; index < locationList.length; index++) {
let item = locationList[index]
SmartDialog.showAttach({
targetId: item.title,
alignment: item.alignment,
maskColor: Color.Transparent,
builder: targetLocationDialog,
})
await delay(300)
}
}, randomColor())
}
.borderRadius(12)
.width(700)
.padding(30)
.backgroundColor(Color.White)
}
@Builder
function buildButton(title: string, onClick?: VoidCallback, bgColor?: ResourceColor) {
Text(title)
.backgroundColor(bgColor ?? "#4169E1")
.constraintSize({ minWidth: 120, minHeight: 46 })
.margin(10)
.textAlign(TextAlign.Center)
.fontColor(Color.White)
.borderRadius(5)
.onClick(onClick)
.id(title)
}
@Builder
function targetLocationDialog() {
Text("targetIdDialog")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.borderRadius(12)
.backgroundColor(randomColor())
}
Loading
对于Loading而言,应该有几个比较明显的特性
- loading和dialog都存在页面上,哪怕dialog打开,loading都应该显示dialog之上
- loading应该具有单一特性,多次打开loading,页面也应该只存在一个loading
- 刷新特性,多次打开loading,后续打开的loading样式,应该覆盖之前打开的loading样式
- loading使用频率非常高,应该支持强大的拓展和极简的使用
从上面列举几个特性而言,loading是一个非常特殊的dialog,所以需要针对其特性,进行定制化的实现
当然了,内部已经屏蔽了细节,在使用上,和dialog的使用没什么区别
默认loading
SmartDialog.showLoading()
自定义Loading
- 点击loading后,会再次打开一个loading,从效果图可以看出它的单一刷新特性
export function loadingCustom() {
SmartDialog.showLoading({
builder: customLoading,
})
}
@Builder
export function customLoading() {
Column({ space: 5 }) {
Text("again open loading").fontSize(16).fontColor(Color.White)
LoadingProgress().width(80).height(80).color(Color.White)
}
.padding(20)
.borderRadius(12)
.onClick(() => loadingCustom())
.backgroundColor(randomColor())
}
最后
鸿蒙版的SmartDialog,相信会对开发鸿蒙的小伙伴们有一些帮助~.~
现在就业环境真是让人头皮发麻,现在的各种技术群里,看到好多人公司各种拖欠工资,各种失业半年的情况
淦,不知道还能写多长时间代码!
来源:juejin.cn/post/7401056900878368807
对比Swift和ArkTS,鸿蒙开发可以这样做
最近在学 ArkTS UI 发现其与 Swift UI 非常像,于是做了一下对比,发现可探索性很强。
Hello World 代码对比
从 Hello World 来看两者就非常像。
鸿蒙 ArtTs UI 的 Hello World 如下:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
}
.height('100%')
.width('100%')
}
}
Swift UI 的 Hello World 如下:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}
同样的 struct 开头,同样的 声明方式,同样的 Text,同样的设置外观属性方法。
不同的是 ArkTS 按照 Typescript 的写法,以装饰器起头,以 build 方法作为初始化的入口,build 里面才是元素;而 Swift UI 整个 ContentView 就是一个大元素,然后开始潜逃内部元素。
声明式 UI 描述
显然,两者都是用了 声明式的 UI 描述。
个人总结声明式 UI 的公式如下:
Element(props) {
SubElement...
}.attribute(value)
元素(元素属性配置) {
子元素
}.元素外观属性(元素外观)
因为 ArkTS 本质上是 Typescript / Javascript ,所以其写法要符合 TS/JS 的写法,引入的属性、变量必须有明确指示。
Text(this.message) // message 是当前页面引入的,要有 this
.id('HelloWorld')
.fontSize(50)
.fontWeight(FontWeight.Bold) // Bold 是公共常量 FontWeight 的其中一值
对于前端来说,简单易读。这里的 Text 传入一个 message,然后标记 id 为 HelloWorld,设置字体为 50,字重为粗体。
而 Swift 或许更符合苹果以前 Obj C 迁移过来的人的写法。
Image(systemName: "globe") // 公共库名:公共库的值
.imageScale(.large) // .large 是公共常量的一个值
.foregroundStyle(.tint)// .tint 是公共常量的一个值
这里的 Image 引入了公共图标库的一个 globe 的图标,然后设置图片大小为大,前景样式为系统主题的颜色,Image(systemName: "globe")
明显不符合 new 一个类的定义方法,.imageScale(.large)
和 .foregroundStyle(.tint)
也不符合参数的使用,按以前的解读会有点让人懵圈。
如果转换成 Typescipt 应该是这样的:
new Image(SystemIcon.globe)
.imageScale(CommonSize.large)
.foregroundStyle(CommonColor.tint)
显然 ArkTS 更符合前端人的阅读、书写习惯。但其实掌握 Swift UI 也并不难,只需要记住 Swift UI 的这些细小差别,写两次也能顺利上手了。
声明式 UI 的耦合性争议
也许不少人会对声明式 UI 的耦合性(M和V耦合在一起)反感。但是在前端来说,除了以前的 的 MVC 框架 Angular.js 外,其余框架即使是 MVVM,也很少能做到解耦合的,特别是单功能内和业务数据交互的耦合。
所以,前端耦合性,还是需要自行处理。
Button({
action: handleGoButton
}).{
Text("Go")
}
// 自行决定 handleGoButton 是否需要放在外部文件中
private func handleGoButton() {
...
}
// 数据耦合在 UI 中,无解
ZStack {
Color(selectedImageIndex == index ?
Color.hex("808080") : Color.hex("585857")) // 选中的背景颜色区别一般的
...
}
前端的解耦合最终还是需要靠组件化、高阶函数来完成:
- 组件化:通过将 UI 分解为独立的组件,每个组件都有自己的功能和状态,可以进一步降低耦合性。组件化使得开发者可以独立地开发和测试组件,而不需要关心其他部分的实现。
- 高阶函数:在某些声明式 UI 框架中,可以使用高阶函数来复用共有逻辑,同时允许替换独有逻辑。这种方式可以减少代码的重复,并提高组件的可重用性。
组件差异
SwiftUI 和鸿蒙操作系统的 ArtTS UI 框架都提供了多种组件,按前端使用情况,其实同样有很多相同之处。
基础组件
基础组件基本相同:
- Text 用于显示文本;
- Image 用于显示图片;
- Button 用户可以点击执行操作;
布局组件
Swift UI 和 ArkTS 相同/相似的布局组件有:
Swift UI | ArkTS | 说明 |
---|---|---|
HStack | Row | 水平堆栈,用于水平排列子视图 |
VStack | Column | 垂直堆栈,用于垂直排列子视图 |
ZStack | Stack | 堆栈视图,用于堆叠多个视图 |
Spacer | Blank | 空白组件,用于占据剩余空间 |
ScrollView | Scroll | 滚动视图,允许用户滚动内容 |
TabsView | Tab | 标签视图,用于创建标签式导航 |
NavigationView | Navigation | 导航视图,用于创建导航结构 |
可以看出,两者基本上的布局都可以通用,当然细节上肯定会有很多不同。不过做一个转译应该不难,可以尝试使用 AI 来完成。
不同的地方在于 Flex 和 Grid 布局:
- Swift UI 仅有懒加载的组件:LazyVGrid 和 LazyHGrid:懒加载的网格视图,用于展示大量数据。
- Ark TS UI有的组件:Flex以弹性方式容器组件:用于灵活布局;Grid网格布局组件:用于创建网格布局。
SwiftUI的布局系统非常灵活,可以通过调整alignment
、spacing
、padding
等属性来实现复杂的布局需求。虽然它不完全等同于CSS中的Flexbox和Grid,但是通过组合使用不同的布局视图,创建出丰富多样的布局效果。
个人在实际开发 iOS 中,通过基础的布局组件搭配,就能完美的实现弹性布局和网格布局。
表单组件
Ark TS UI 提供了一系列的组件,更接近于 html 同名的组件:
- TextInput:用于文本输入。
- CheckBox 和 Switch:用于布尔值的选择。
- Radio:用于单选按钮组,类似于 HTML 中的单选按钮。
- Picker:用于选择器,可以用于日期选择、时间选择或简单的选项选择,类似于 HTML 中的
<select>
。
Ark TS UI 表单组件的特点在于它们与数据绑定紧密集成,可以通过 @State
、@Link
和 @Prop
等装饰器来管理表单的状态和数据流。
而 Swift UI 也有类似的表单组件,但是大部分都不相同:
- TextField:用于文本输入,可以包含占位符文本。
- SecureField:用于密码输入,隐藏输入内容。
- Picker:用于选择器,可以用于选择单个值或多个值。
- Toggle:用于布尔值的选择,类似于开关。
- DatePicker:用于日期和时间选择。
- Slider 和 Stepper:用于数值选择,
Slider
提供连续值选择,而Stepper
提供步进值选择。 - Form:一个容器视图,用于组织输入表单数据,使得表单的创建和管理更为方便。
虽然不能一一对应,但像日期选择器那样,目前大部分用户已经基本适应了 android 和 iOS 的差异。
总结 - 可运用 AI 转译
通过这次对比学习,可以得出以下结论:
- 声明式 UI 是前端更容易配置与阅读,尤其是 ArkTS ;
- 解耦合需要运用组件化、高阶函数等知识进行自行处理;
- ArkTS UI 和 Swift UI 的基础组件、布局组件相似度非常高,基本能一一对应,可以对照学习使用;
- 鉴于两者相似度高,可以尝试开发一个 app,然后另一个 app 使用 AI 来完成转译。
第四点,个人觉得难度不大,本人用代码差异非常大的 android app 转译 iOS app 的也能成功,只是一个个页面进行调试花了不少时间。
至于先开发哪个 app 看你个人习惯,如果你是老 iOS 开发,可以使用先开发 iOS 再进行鸿蒙 OS 开发;甚至 react native 开发生成 iOS 之后,通过生成的 iOS 代码进行转译鸿蒙 OS app。如果你没有之前的负担,完全可以学习 ArkTS,更快地入手,然后通过转译 iOS app 来学习 Swift。
来源:juejin.cn/post/7449173391443329078
跨窗口通信的九重天劫:从postMessage到BroadcastChannel
跨窗口通信的九重天劫:从postMessage到BroadcastChannel
第一重:postMessage 基础劫 —— 安全与效率的平衡术
// 父窗口发送
const child = window.open('child.html');
child.postMessage({ type: 'AUTH_TOKEN', token: 'secret' }, 'https://your-domain.com');
// 子窗口接收
window.addEventListener('message', (e) => {
if (e.origin !== 'https://parent-domain.com') return;
console.log('收到消息:', e.data);
});
安全守则:
- 始终验证
origin
属性 - 敏感数据使用
JSON.stringify
+加密
- 使用
transfer
参数传递大型二进制数据(如ArrayBuffer)
第二重:MessageChannel 双生劫 —— 高性能私有通道
// 建立通道
const channel = new MessageChannel();
// 端口传递
parentWindow.postMessage('INIT_PORT', '*', [channel.port2]);
// 接收端处理
channel.port1.onmessage = (e) => {
console.log('通过专用通道收到:', e.data);
};
// 发送消息
channel.port1.postMessage({ priority: 'HIGH', payload: data });
性能优势:
- 相比普通postMessage减少50%的序列化开销
- 支持传输10MB以上文件(Chrome实测)
第三重:BroadcastChannel 广播劫 —— 同源全域通信
// 发送方
const bc = new BroadcastChannel('app-channel');
bc.postMessage({ event: 'USER_LOGOUT' });
// 接收方
const bc2 = new BroadcastChannel('app-channel');
bc2.onmessage = (e) => {
if (e.data.event === 'USER_LOGOUT') {
localStorage.clear();
}
};
适用场景:
- 多标签页状态同步
- 全局事件通知系统
- 跨iframe配置更新
第四重:SharedWorker 共享劫 —— 持久化通信枢纽
// worker.js
const connections = [];
onconnect = (e) => {
const port = e.ports[0];
connections.push(port);
port.onmessage = (e) => {
connections.forEach(conn => {
if (conn !== port) conn.postMessage(e.data);
});
};
};
// 页面使用
const worker = new SharedWorker('worker.js');
worker.port.start();
worker.port.postMessage('来自页面的消息');
内存管理:
- 每个SharedWorker实例共享同一个全局作用域
- 需要手动清理断开连接的端口
第五重:localStorage 事件劫 —— 投机取巧的同步
// 页面A
localStorage.setItem('sync-data', JSON.stringify({
timestamp: Date.now(),
data: '重要更新'
}));
// 页面B
window.addEventListener('storage', (e) => {
if (e.key === 'sync-data') {
const data = JSON.parse(e.newValue);
console.log('跨页更新:', data);
}
});
致命缺陷:
- 事件仅在其他页面触发
- 同步API导致主线程阻塞
- 无法传递二进制数据
第六重:IndexedDB 观察劫 —— 数据库驱动通信
// 建立观察者
let lastVersion = 0;
const db = await openDB('msg-db', 1);
db.transaction('messages')
.objectStore('messages')
.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor && cursor.value.version > lastVersion) {
lastVersion = cursor.value.version;
handleMessage(cursor.value);
}
};
// 写入新消息
await db.add('messages', {
version: Date.now(),
content: '新订单通知'
});
适用场景:
- 需要持久化保存的通信记录
- 离线优先的跨窗口消息队列
第七重:Window.name 穿越劫 —— 上古秘术
// 页面A
window.name = JSON.stringify({ session: 'temp123' });
location.href = 'pageB.html';
// 页面B
const data = JSON.parse(window.name);
console.log('穿越传递:', data);
安全警告:
- 数据暴露在所有同源页面
- 最大容量约2MB
- 现代应用已不建议使用
第八重:Server-Sent Events (SSE) 服务劫 —— 服务器中转
// 服务端(Node.js)
app.get('/updates', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
setInterval(() => {
res.write(`data: ${Date.now()}\n\n`);
}, 1000);
});
// 浏览器端
const es = new EventSource('/updates');
es.onmessage = (e) => {
allWindows.forEach(w => w.postMessage(e.data));
};
架构优势:
- 支持跨设备同步
- 自动重连机制
- 与WebSocket互补(单向vs双向)
第九重:WebSocket 广播劫 —— 实时通信终极形态
// 共享连接管理
const wsMap = new Map();
function connectWS() {
const ws = new WebSocket('wss://push.your-app.com');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'BROADCAST') {
broadcastToAllTabs(data.payload);
}
};
return ws;
}
// 页面可见性控制
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
ws.close();
} else {
ws = connectWS();
}
});
性能优化:
- 心跳包维持连接(每30秒)
- 消息压缩(JSON → ArrayBuffer)
- 退避重连策略
渡劫指南(技术选型矩阵)
graph LR
A[是否需要持久化?] -->|是| B[IndexedDB]
A -->|否| C{实时性要求}
C -->|高| D[WebSocket]
C -->|中| E[BroadcastChannel]
C -->|低| F[postMessage]
B --> G[是否需要跨设备?]
G -->|是| H[SSE/WebSocket]
G -->|否| I[localStorage事件]
天劫问答
- 如何防止跨窗口消息风暴?
- 采用消息节流(throttle)
- 使用
window.performance.now()
标记时序 - 实施优先级队列
- 哪种方式最适合微前端架构?
BroadcastChannel
全局通信 +postMessage
父子隔离
- 如何实现跨源安全通信?
- 使用
iframe
作为代理中继 - 配合CORS和
document.domain
设置
- 使用
调试工具推荐
- Charles - 抓取WebSocket消息
- Window Query - 查看所有窗口对象
- Postman - 模拟SSE事件流
性能检测代码:
// 通信延迟检测
const start = performance.now();
channel.postMessage('ping');
channel.onmessage = () => {
console.log('往返延迟:', performance.now() - start);
};
来源:juejin.cn/post/7498619063671046196
你可能不知道的前端18个冷知识
今天带大家盘点一下前端的一些冷知识。
一、浏览器地址栏的妙用
1.1 可以执行javascript代码
在地址栏中输入javascript:alert('hello world')
,然后按回车键,会弹出一个提示框显示hello world
。
注意:如果直接把这段代码复制到地址栏,浏览器会删除掉前面
javascript:
(比如谷歌浏览器、edge浏览器等),需要自己手动加上。
还可以使用location.href
和window.open
来执行它。
location.href = "javascript:alert('hello world')";
window.open("javascript:alert('hello world')");
1.2 可以运行html
在地址栏中输入data:text/html,<div>hello world</div>
,然后按回车键,会显示一个包含hello world
的div元素。
利用这个能力,我们可以把浏览器标签页变成一个编辑器。
contenteditable
属性能把一个元素变成可编辑的,所以我们如果在地址栏中输入data:text/html,<html contenteditable>
,就可以把页面直接变成一个编辑器了。你还可以把它收藏到书签,以后直接点击就可以打开一个编辑器了。
二、把整个在线网页变成可编辑
只需要在浏览器控制台中输入这样一行代码,就能把整个页面变成可编辑的。
document.body.contentEditable = 'true';
这样我们就能随便修改页面了,比如修改页面中的文字、图片等等,轻松实现修改账户余额去装逼!
三、利用a标签解析URL
const a = document.createElement('a');
a.href = 'https://www.baidu.com/s?a=1&b=1#hash';
console.log(a.host); // http://www.baidu.com
console.log(a.pathname); // /s
console.log(a.search); // ?a=1&b=1
console.log(a.hash); // #hash
四、HTML的ID和全局变量的映射关系
在HTML中,如果有一个元素的id是a
,那么在全局作用域中,会有一个变量a
,这个变量指向这个元素。
<div id="a"></div>
<script>
console.log(a); // <div id="a"></div>
</script>
如果id重复了,还是会生成一个全局变量,但是这个变量指向的是一个HTMLCollection
类数组。
<div id="a">a</div>
<div id="a">b</div>
<script>
console.log(a); // HTMLCollection(2) [div#a, div#a]
</script>
五、cdn加载省略协议头
<script src="//cdn.xxx.com/xxx.js"></script>
src的值以//开头,省略了协议,则在加载js时,会使用当前页面的协议进行加载。
如果当前页面是https
则以https
进行加载。
如果当前页面是http
则以http
进行加载。
如果当前页面是ftp
则以ftp
进行加载。
六、前端的恶作剧:隐藏鼠标光标
<style>
* {
cursor: none !important;
}
</style>
直接通过css把光标隐藏,让人哭笑不得。
七、文字模糊效果
前端文本的马赛克效果,可以使用text-shadow
实现。
<style>
.text {
color: transparent;
text-shadow: #111 0 0 5px;
user-select: none;
}
</style>
<span>hello</span><span class="text">world</span>
效果如下:
八、不借助js和css,让元素消失
直接用DOM自带的hidden
属性即可。
<div hidden>hello world</div>
九、保护隐私
禁用F12
快捷键:
document.addEventListener('keydown', (e) => {
if (e.keyCode === 123) {
e.preventDefault();
}
})
禁用右键菜单:
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
})
但即使通过禁用F12
快捷键和右键菜单,用户依然可以通过其它方式打开控制台。
- 通过浏览器菜单选项直接打开控制台:比如
chrome
浏览器通过菜单 > 更多工具 > 开发者工具
路径可以打开控制台,Firefox/Edge/Safari
等浏览器都有类似选项。 - 用户还可以通过其它快捷键打开控制台:
- Cmd+Opt+I (Mac)
- Ctrl+Shift+C (打开检查元素模式)
十、css实现三角形
<style>
.triangle {
width: 0;
height: 0;
border: 20px solid transparent;
border-top-color: red;
}
</style>
<div class="triangle"></div>
十一、为啥 a === a-1 结果为true
当a
为Infinity
无穷大时,a - 1
的结果也是Infinity
,所以a === a - 1
的结果为true
。
同理,a
的值为-Infinity
时,此等式也成立。
const a = Infinity;
console.log(a === a - 1);
十二、数字的包装类
console.log(1.toString()); // 报错
console.log(1..toString()); // 正常运行 输出字符串'1'
十三、防止网站以 iframe 方式被加载
if (window.location !== window.parent.location) window.parent.location = window.location;
十四、datalist的使用
datalist
是 HTML5
中引入的一个新元素,它用于为<input>
元素提供预定义的选项列表。就是当用户在下拉框输入内容时,浏览器会显示一个下拉列表,列表的内容就是与当前输入内容相匹配的 datalist
选项。
<input list="fruits" name="fruit" />
<datalist id="fruits">
<option value="苹果"></option>
<option value="橘子"></option>
<option value="香蕉"></option>
</datalist>
效果如下:
十五、文字纵向排列
<style>
.vertical-text {
writing-mode: vertical-rl;
text-orientation: upright;
}
</style>
<div class="vertical-text">文字纵向排列</div>
效果如下:
十六、禁止选中文字
document.addEventListener('selectstart', (e) => {
e.preventDefault();
})
效果跟使用 css 的 user-select: none
效果类似。
十七、利用逗号,在一行中执行多个表达式
let a = 1;
let b = 2;
(a += 2), (b += 3);
十八、inset
inset
是一个简写属性,用于同时设置元素的 top
、right
、bottom
和 left
属性
.box {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
可以简写成:
.box {
position: absolute;
inset: 0;
}
小结
以上就是前端的18个冷知识,希望大家看完都有所收获。
来源:juejin.cn/post/7502059146641784883
Day.js 与 Moment.js 比较
Day.js 与 Moment.js 的比较
优点
- 体积小:Day.js 的体积仅为 2KB 左右,而 Moment.js 的体积约为 67KB。
- API 相似:Day.js 的 API 与 Moment.js 高度相似,迁移成本低。
- 不可变性:Day.js 的日期对象是不可变的,这意味着每次操作都会返回一个新的日期对象,避免了意外的副作用。
缺点
- 功能较少:Day.js 的功能相对 Moment.js 较少,特别是在处理时区和复杂日期操作时。
- 插件依赖:一些高级功能(如时区支持)需要通过插件实现,增加了额外的依赖。
定位与设计理念
- Moment.js
- 老牌时间处理库,2012 年发布,曾是 JavaScript 时间处理的事实标准,功能全面且语法直观。
- 设计目标:覆盖几乎所有时间处理需求,包括复杂的时区、本地化、格式化、操作等。
- 现状:2020 年进入 维护模式(不再新增功能,仅修复严重 bug),官方推荐迁移至更现代的库(如 Day.js、Luxon 等)。
- Day.js
- 轻量替代方案,2018 年发布,设计灵感直接来源于 Moment.js,语法高度相似,但更简洁轻量。
- 设计目标:通过最小化核心功能 + 插件机制,提供常用时间操作能力,避免过度设计。
- 现状:持续活跃更新,由单一开发者维护,社区支持度快速增长。
核心差异对比
维度 | Moment.js | Day.js |
---|---|---|
体积 | 约 40KB+ (完整版本),包含大量功能模块。 | 仅 2KB(核心库),插件按需引入,体积极小。 |
API 设计 | 功能全面(如 localeData() , utcOffset() , tz() 等),部分高级功能略显复杂。 | 极简 API,保留高频操作(如 format() , add() , diff() 等),链式调用风格与 Moment 一致,学习成本低。 |
功能完整性 | 原生支持时区(需单独引入 moment-timezone 插件)、复杂本地化、相对时间、ISO 8601 等,无需额外依赖。 | 核心库仅包含基础功能,时区(需 dayjs-plugin-timezone 插件)、本地化(需 dayjs/plugin/locales )等需手动安装插件,灵活性高但需配置。 |
性能 | 解析和操作大型时间数据时性能中等,体积大导致加载速度较慢。 | 轻量核心 + 按需加载,解析和操作速度更快,尤其在移动端或高频时间处理场景优势明显。 |
浏览器支持 | 兼容 IE 8+ 及现代浏览器,对旧版浏览器友好。 | 依赖 ES6+(如 Promise , Proxy ),支持现代浏览器(Chrome 49+, Firefox 52+, 等),不支持 IE。 |
生态与社区 | 生态成熟,周边工具丰富(如 Webpack 插件、React 组件等),但更新停滞。 | 生态快速发展中,主流框架(如 Vue、React)适配良好,插件系统完善(官方维护 20+ 插件)。 |
维护状态 | 进入维护模式,仅安全更新,无新功能。 | 活跃维护,定期发布新版本,快速响应社区需求。 |
Dayjs中文文档
如何将 Moment.js 替换为 Day.js
1. 安装 Day.js
首先,安装 Day.js:
npm install dayjs
2. 替换导入语句
将项目中的 Moment.js 导入语句替换为 Day.js:
// 将
import moment from 'moment';
// 替换为
import dayjs from 'dayjs';
3. 替换 API 调用
将 Moment.js 的 API 调用替换为 Day.js 的等效调用。由于两者的 API 非常相似,大多数情况下只需简单替换即可:
// Moment.js
const date = moment('2023-10-01');
console.log(date.format('YYYY-MM-DD'));
// Day.js
const date = dayjs('2023-10-01');
console.log(date.format('YYYY-MM-DD'));
4. 处理差异
在某些情况下,Day.js 和 Moment.js 的行为可能略有不同。你需要根据具体情况调整代码。例如,Day.js 的 diff
方法返回的是毫秒数,而 Moment.js 返回的是天数:
// Moment.js
const diff = moment('2023-10-02').diff('2023-10-01', 'days'); // 1
// Day.js
const diff = dayjs('2023-10-02').diff('2023-10-01', 'day'); // 1
5. 引入插件(可选)
如果你需要使用 Day.js 的高级功能(如时区支持),可以引入相应的插件:
5. 总结:如何选择?
- 选 Moment.js:如果项目依赖其成熟生态、需要兼容旧浏览器,或时间逻辑极其复杂且不愿配置插件。
- 选 Day.js:如果追求轻量、高性能、简洁 API,且能接受通过插件扩展功能(推荐新项目使用)。
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
const date = dayjs().tz('America/New_York');
console.log(date.format('YYYY-MM-DD HH:mm:ss'));
总结:
两者语法高度相似,迁移成本低。若项目对体积和性能敏感,Day.js 是更优解;若功能全面性和旧项目兼容更重要,Moment.js 仍可短期使用,但长期建议迁移至活跃库(如 Day.js 或 Luxon)。
来源:juejin.cn/post/7499005521116545062
鸿蒙中的长列表「LazyForEach」:起猛了,竟然在鸿蒙系统上看到了「RecyclerView」?
声明式UI && 命令式UI
传统的命令式UI
编程范式中,开发者需要明确地指示系统如何一步一步地构建和更新UI,手动处理每一个UI更新和状态变化,随着应用复杂度增加,管理UI和状态同步变得更加困难。所以声明式UI
应运而生,它的出现就是为了简化UI开发,减少手动管理状态和UI更新的复杂性。现代前端框架(Jetpack Compose、SwiftUI)都采用了声明式UI的编程范式。
在声明式UI编程范式中,开发者不再手动构建、更新UI,而是「描述界面应该是什么样子的」:开发者定义界面状态,然后框架会根据状态自动更新UI。
相对于命令式UI,声明式UI更加简洁和易于维护,但缺乏了灵活性——开发者无法完全控制UI更新的粒度。所以声明式UI的性能是一大挑战,尤其是复杂长列表场景下的性能问题。
为了解决长列表的渲染问题,Jetpack Compose 提供了LazyColumn
和LazyRow
等组件,SwiftUI
也有List
和LazyVStack
等组件。作为鸿蒙系统的UI体系ArkUI
自然也有用于长列表的组件LazyForEach
:
LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。
LazyForEach用法
本文就针对ArkUI中的LazyForEach
来探究一二。
LazyForEach 的渲染依赖IDataSource
和DataChangeListener
,我们一个一个来看下:
IDataSource
LazyForEach
的数据获取、更新都是通过IDataSource
来完成的:
totalCount(): number
获得数据总数getData(index: number): Object
获取索引值index对应的数据registerDataChangeListener(listener: DataChangeListener)
注册数据改变的监听器unregisterDataChangeListener(listener: DataChangeListener)
注销数据改变的监听器
DataChangeListener
DataChangeListener
,官方定义其为数据变化监听器,用于通知LazyForEach
组件数据更新。除掉已废弃的方法外,共有以下几个方法:
onDataReloaded()
通知组件重新加载所有数据。键值没有变化的数据项会使用原先的子组件,键值发生变化的会重建子组件。重新加载数据完成后调用。onDataAdd(index: number)
通知组件index的位置有数据添加。添加数据完成后调用onDataMove(from: number, to: number)
通知组件数据有移动。将from和to位置的数据进行交换。数据移动起始位置与数据移动目标位置交换完成后调用。onDataDelete(index: number)
通知组件删除index位置的数据并刷新LazyForEach的展示内容。删除数据完成后调用。onDataChange(index: number)
通知组件index的位置有数据有变化。改变数据完成后调用。onDatasetChange(dataOperations: DataOperation[])
进行批量的数据处理,该接口不可与上述接口混用。批量数据处理后调用。
披着马甲的RecyclerView?
这...这不对吧?你给我干哪儿来了?这还是国内么?
相信大部分Android开发者看到LazyForEach的API都是这样两眼一黑:这...这确定不是RecyclerView?连API都能一一对应上:
- DataChangeListener.onDataReloaded() -> RecyclerView.Adapter.notifyDataSetChanged()
- DataChangeListener.onDataAdd() -> RecyclerView.Adapter.notifyItemInserted()
- DataChangeListener.onDataDelete() -> RecyclerView.Adapter.notifyItemRangeRemoved()
- DataChangeListener.onDataChange() -> RecyclerView.Adapter.notifyItemChanged()
一个简单的demo
我们写一个简单的长列表来体验下鸿蒙的LazyForEach
用法:页面顶部3个按钮对应列表的增、删、改功能,列表的item显示当前item的index,数据源部分代码如下:
class BasicDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private originDataArray: string[] = [];
public totalCount(): number {
return 0;
}
public getData(index: number): string {
return this.originDataArray[index];
}
// 该方法为框架侧调用,为LazyForEach组件向其数据源处添加listener监听
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
console.info('add listener');
this.listeners.push(listener);
}
}
// 该方法为框架侧调用,为对应的LazyForEach组件在数据源处去除listener监听
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) {
console.info('remove listener');
this.listeners.splice(pos, 1);
}
}
// 通知LazyForEach组件需要重载所有子组件
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded();
})
}
// 通知LazyForEach组件需要在index对应索引处添加子组件
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index);
})
}
// 通知LazyForEach组件在index对应索引处数据有变化,需要重建该子组件
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index);
})
}
// 通知LazyForEach组件需要在index对应索引处删除该子组件
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index);
})
}
// 通知LazyForEach组件将from索引和to索引处的子组件进行交换
notifyDataMove(from: number, to: number): void {
this.listeners.forEach(listener => {
listener.onDataMove(from, to);
})
}
}
export class MyDataSource extends BasicDataSource {
private dataArray: string[] = [];
public totalCount(): number {
return this.dataArray.length;
}
public getData(index: number): string {
return this.dataArray[index];
}
public addData(index: number, data: string): void {
this.dataArray.splice(index, 0, data);
this.notifyDataAdd(index);
}
public pushData(data: string): void {
this.dataArray.push(data);
this.notifyDataAdd(this.dataArray.length - 1);
}
public deleteData(index: number): void {
this.dataArray.splice(index, 1);
this.notifyDataDelete(index);
}
public changeData(index: number, data: string): void {
this.dataArray.splice(index, 1, data);
this.notifyDataChange(index);
}
}
UI部分正常使用LazyForEach
展示数据即可:
@Entry
@Component
struct Index {
private data: MyDataSource = new MyDataSource();
aboutToAppear(): void {
for (let i = 0; i <= 4; i++) {
this.data.pushData(`index ${i}`)
}
}
build() {
Column() {
Button('add')
.borderRadius(8)
.backgroundColor(0x317aff)
.margin({top: 12, left: 20, right: 20})
.width(360)
.height(40)
.onClick(() => {
const lastIndex = this.data.totalCount()
this.data.addData(lastIndex, `index ${lastIndex}`)
})
Button('remove')
.borderRadius(8)
.backgroundColor(0xF55A42)
.margin({top: 12, left: 20, right: 20})
.width(360)
.height(40)
.onClick(() => {
const lastIndex = this.data.totalCount()
this.data.notifyDataMove(lastIndex - 1, lastIndex - 1)
})
List({ space: 3 }) {
LazyForEach(this.data, (item: string) => {
ListItem() {
Row() {
Text(item)
.fontSize(40)
.textAlign(TextAlign.Center)
.width('100%')
.height(55)
.borderRadius(8)
.backgroundColor(0xF5F5F5)
.onAppear(() => {
console.info("appear:" + item)
})
}.margin({ left: 10, right: 10 , top: 10 })
}
}, (item: string) => item)
}.cachedCount(5)
.width('100%')
.height('auto')
.layoutWeight(1)
}.width('100%')
.height('100%')
}
}
demo功能也很简单:
- 点击
add
按钮在列表底部添加新元素 - 点击
remove
按钮删除列表底部最后一个元素 - 点击
update
按钮在将第一个元素文案更新为index new 0
那如果是复杂的数据更新操作呢?
比如列表原来的数据为 ['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e']
,经过一系列变化后需要调整成['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d']
,这时候如何更新UI展示?
此时就需要用到onDatasetChange(dataOperations: DataOperation[])
API了:
#BasicDataSource
class BasicDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
console.info('add listener');
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) {
console.info('remove listener');
this.listeners.splice(pos, 1);
}
}
notifyDatasetChange(operations: DataOperation[]): void {
this.listeners.forEach(listener => {
listener.onDatasetChange(operations);
})
}
}
#MyDataSource
class MyDataSource extends BasicDataSource {
private dataArray: string[] = ['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e'];
public operateData(): void {
this.dataArray =
['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d']
this.notifyDatasetChange([
{ type: DataOperationType.CHANGE, index: 0 },
{ type: DataOperationType.ADD, index: 1, count: 2 },
{ type: DataOperationType.EXCHANGE, index: { start: 3, end: 4 } },
]);
}
}
复杂的数据操作需要我们告诉组件如何变化,以上述的例子为例:
// 修改之前的数组
['Hello a', 'Hello b', 'Hello c', 'Hello d', 'Hello e']
// 修改之后的数组
['Hello x', 'Hello 1', 'Hello 2', 'Hello b', 'Hello c', 'Hello e', 'Hello d']
- 第一个元素从'Hello a'变为'Hello x',因此第一个operation为
{ type: DataOperationType.CHANGE, index: 0 }
- 新增了元素'Hello 1'和'Hello 2',下标为1和2,所以第二个operation为
{ type: DataOperationType.ADD, index: 1, count: 2 }
- 元素'Hello d'和'Hello e'交换了位置,所以第三个operation为
{ type: DataOperationType.EXCHANGE, index: { start: 3, end: 4 } }
使用onDatasetChange(dataOperations: DataOperation[])
API时需要注意:
- onDatasetChange与其它操作数据的接口不能混用。
- 传入onDatasetChange的operations,其中每一项operation的index均从修改前的原数组内寻找。因此,opeartions中的index跟操作Datasource中的index不是一一对应的。
- 调用一次onDatasetChange,一个index对应的数据只能被操作一次,若被操作多次,LazyForEach仅使第一个操作生效。
- 部分操作可以由开发者传入键值,LazyForEach不会再去重复调用keygenerator获取键值,需要开发者保证传入的键值的正确性。
- 若本次操作集合中有RELOAD操作,则其余操作全不生效。
通过@Observed 更新子组件
在LazyForEach循环渲染过程中,系统会为每个item生成一个唯一且持久的键值,用于标识对应的组件。当这个键值变化时,ArkUI框架将视为该数组元素已被替换或修改,并会基于新的键值创建一个新的组件。
LazyForEach提供了一个名为keyGenerator的参数,这是一个函数,开发者可以通过它自定义键值的生成规则。如果开发者没有定义keyGenerator函数,则ArkUI框架会使用默认的键值生成函数,即(item: Object, index: number) => { return viewId + '-' + index.toString(); }, viewId在编译器转换过程中生成,同一个LazyForEach组件内其viewId是一致的。
上述的列表更新都是依靠LazyForEach
的刷新机制:当item变化时,通过将将原来的子组件全部销毁再重新构建的方式来更新子组件。这种通过改变键值去刷新的方式渲染性能较低。因此鸿蒙系统也提供了@Observed
机制进行深度观测,可以做到仅刷新使用了该属性的组件,提高渲染性能。还是上面的例子,这次我们将数据源换成被@Observed
修饰的类:
@Observed
class StringData {
message: string;
constructor(message: string) {
this.message = message;
}
}
@Entry
@Component
struct MyComponent {
private moved: number[] = [];
@State data: MyDataSource = new MyDataSource();
aboutToAppear() {
for (let i = 0; i <= 20; i++) {
this.data.pushData(new StringData(`Hello ${i}`));
}
}
build() {
List({ space: 3 }) {
LazyForEach(this.data, (item: StringData, index: number) => {
ListItem() {
ChildComponent({data: item})
}
.onClick(() => {
item.message += '0';
})
}, (item: StringData, index: number) => index.toString())
}.cachedCount(5)
}
}
@Component
struct ChildComponent {
@Prop data: StringData
build() {
Row() {
Text(this.data.message).fontSize(50)
.onAppear(() => {
console.info("appear:" + this.data.message)
})
}.margin({ left: 10, right: 10 })
}
}
此时点击LazyForEach
子组件改变item.message
时,重渲染依赖的是ChildComponent
的@Prop
成员变量对其子属性的监听,此时框架只会刷新Text(this.data.message)
,不会去重建整个ListItem子组件。
实际开发时,开发者需要根据其自身业务特点选择使用哪种刷新方式:改变键值 or 通过@Observed
算是吐槽?
作为一名Android开发者,使用LazyForEach后,彷佛看到了故人之姿。用法和API设计都和RecyclerView
太像了,甚至RecyclerView需要注意的用法上的问题,LazyForEach同样也有:
关于ScrollView嵌套RecyclerView使用上的问题,可以移步:
实名反对《阿里巴巴Android开发手册》中NestedScrollView嵌套RecyclerView的用法
不同的是,早期的RecyclerView
出来让人惊艳:相比于它的前辈 ListView
,同时通过Adapter将数据和UI隔离,设计非常灵活,可拓展性非常强。
然而使用LazyForEach
时我却总有些恍惚:不是声明式UI么?不是应该描述、定义列表界面状态,然后ArkUI框架根据列表状态自动完成UI的更新么?为什么还会有DataChangeListener
这种东西存在?
官方文档里也明确表示了LazyForEach不支持状态变量:
LazyForEach必须使用DataChangeListener对象进行更新,对第一个参数dataSource重新赋值会异常;dataSource使用状态变量时,状态变量改变不会触发LazyForEach的UI刷新。
猜测还是和性能有关系,所以官方也没将LazyForEach归类为容器组件
而是把它划到了渲染控制
模块里。不过个人觉得这种违背声明式UI的初衷,将逻辑抛给开发者的方式并不可取。
对比之下,同样是声明式UI的Compose
在长列表的处理就显得优雅了许多:
var items by remember { mutableStateOf(listOf("Item 0", "Item 1", "Item 2")) }
@Composable
fun LazyColumnDemo() {
var items by remember { mutableStateOf(listOf("Item 0", "Item 1", "Item 2")) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = {
items = items + "Item ${items.size}"
}) {
Text("Add Item")
}
Button(onClick = {
if (items.isNotEmpty()) {
items = items.dropLast(1)
}
}) {
Text("Remove Item")
}
Button(onClick = {
if (items.isNotEmpty()) {
items = items.toMutableList().apply {
this[0] = "new"
}
}
}) {
Text("Update First")
}
}
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(items) { index, item ->
ListItem(index = index, text = item)
}
}
}
}
来源:juejin.cn/post/7410590100965572643
中原银行鸿蒙版开发实战
一、建设背景
2024年1月18日,HarmonyOS NEXT鸿蒙星河版亮相,标志着“纯血鸿蒙”正式开始扬帆起航。同年6月21日,在华为开发者大会上HarmonyOS Next正式发布,并且将于第4季度发布商用版。
中原银行App用户中华为机型占比第一,及时兼容鸿蒙系统,能够为使用华为设备的客户提供更好的服务,同时适配鸿蒙系统也可以支持我国科技创新和提升金融系统安全性。
二、建设历程
2024年1月,中原银行App鸿蒙版项目启动;
2024年4月,完成整体研发工作;
2024年6月,完成功能测试、安全测试等测试工作;
2024年6月14日,正式在华为应用市场上架。
三、关键技术
1. 混合开发框架
中原银行鸿蒙版应用架构为四层架构,分别为基础环境层、基础库层、混合开发框架层、业务模块层。
基础环境层: 主要是一些基础设施及环境配置,如OHPM私仓搭建;
基础库层: 主要是应用中使用的基础功能组件,如网络、加解密等;
混合开发框架层: 采用混合开发模式,各业务模块以中原银行小程序的形式开发,拥有“一次开发、多端适用”和迭代发版灵活快速等特性。基于混合开发框架,原有Android和iOS上运行的小程序可无缝运行在鸿蒙设备上,极大提高开发效率。
为进一步优化用户体验与性能,自研JsBridge,有效降低了小程序与原生系统间交互的性能损耗,确保流畅的交互体验。同时,采用离线下载机制,将小程序代码及资源通过离线包形式预先下载至本地,配合离线包校验机制,显著提升了小程序加载速度,同时增强了小程序安全性。此外,引入预加载策略,针对公共代码进行预加载处理,并使用C语言优化资源加载逻辑,进一步提升了整体加载性能。
业务模块层: 主要是应用中各业务功能,如存款、理财、登录等。
图3.1 中原银行鸿蒙版架构图
2. 传输安全
为满足金融app对网络传输的安全、性能及复杂业务逻辑要求,使用分层拦截器将复杂的网络请求进行加解密、gzip、防重防等功能的拆分、解耦,增加网络传输过程安全性、可靠性。其中由于鸿蒙原生密钥协商算法暂不支持国密算法,项目中引入铜锁密码库,替换鸿蒙ECDH密钥协商算法,实现了对国密SM2_256的密钥协商算法支持,满足了监管对国密算法使用的要求;针对加密zip包解压和tar包文件读取,我们定制裁剪minizip-ng和libtar开源c库,通过napi实现arkTs与C库之间的相互调用,最终完成对加密zip包解压和tar包特定文件读取的支持。
图3.2 网络分层拦截器
图3.3 加解密流程
3. OHPM私仓搭建
由于金融网络与互联网网络隔离,金融网络环境下无法直接访问互联网上的鸿蒙中心仓库 ohpm.openharmony.cn,导致开发环境无法正常使用,同时需要一个仓库来存放私有类库,为此我们搭建了 OHPM 私有仓库,实现了金融网络环境下 OHPM 仓库的正常使用,并且可一键安装内网专用包和外网公共包,为金融网络内鸿蒙应用开发打下坚实基础。
具体操作为:使用OHPM 私仓搭建工具(developer.huawei.com/consumer/cn…),配置“上游”鸿蒙相关仓库地址(ohpm.openharmony.cn),通过公司内专用互联网代理通道代理到鸿蒙中心仓库。现将搭建过程遇到的部分问题总结如下:
(1)由于内网中无法申请到 HTTPS 证书,私仓无法以 HTTPS 方式部署,我们改造了 OHPM 底层网络代码,对使用 HTTPS 的“上游”仓库,改为 HTTP 代理,改造代码如下:
// 改造 ohpm 源代码,解决内网申请不了 https 证书的问题
// 文件: libs/service/uplinks/uplink-proxy/UplinkProxyService.js
// 改造 ohpm 源代码,解决内网申请不了 https 证书的问题
// 文件: libs/service/uplinks/uplink-proxy/UplinkProxyService.js
if ("https:" === t.protocol.trim()) {
const t = e.https_proxy;
// 对 https 的上游仓库,使用 http 代理
t && (o = new i.HttpProxyAgent(t));
}
(2)原版搭建工具为前台启动,可靠性低,日志难以管理。在部署过程中,我们使用了守护进程管理工具PM2用于提升服务可靠性并记录日志,配置代码如下:
// 使用 pm2 实现守护进程管理
// 文件: pm2.config.js
module.exports = {
apps: [
{
// 服务名称
name: "ohpm-repo",
// 私仓搭建工具的所在目录
cwd: "/path/to/ohpm-repo",
// 入口脚本
script: "index.js",
// 集群模式启动,提升服务可靠性
exec_mode: "cluster",
// 实例数量
instances: 2,
// 崩溃时自动重启服务
autorestart: true,
// 不需要监听文件变化
watch: false,
// 内存时重新启动
max_memory_restart: "1G",
// 将控制台日志输出到文件
error_file: "./logs/ohpm-repo-error.log",
out_file: "./logs/ohpm-repo-out.log",
merge_logs: true,
// 环境变量
env_production: {
NODE_ENV: "production",
},
},
],
};
四、鸿蒙特性实践
1. 原生智能
鸿蒙原生系统已深度集成了多项AI能力,例如OCR识别、语音识别等。我们在个人信息设置、贷款信息录入等场景集成了鸿蒙Vision Kit组件,通过扫描身-份-证/银彳亍卡的方式录入客户信息,不仅提升了客户使用的便捷性,还确保了交易的安全性;后续还会在客户上传正件照片时集成智能PhotoPicker,当客户需要上传正件照时,系统智能地从图库中选出正件类照片优先展示,极大地提升用户使用体验;在搜索等场景集成Core Speech Kit组件,通过语音识别实现说话代替手工打字输入,使得输入操作更便捷、内容更准确,后续计划将该能力扩展至智能客服交互和老年版界面播报场景,真正地实现智能贴心服务。
2. 终端安全
鸿蒙设备为开发者提供了基于可信执行环境(TEE)的关键资产存储服务(Asset Store Kit),确保用户敏感数据无法被恶意获取和篡改。我们在可信终端识别场景,通过采集鸿蒙基础环境信息,配合相关唯一标识算法计算出设备的标识码,为防止该标识码被恶意篡改或因应用卸载重装发生变化,利用Asset Store Kit将该标识缓存于设备TEE中,再结合云端关联匹配与碰撞检测机制, 充分保证了标识码的稳定性与抗欺骗性,为应用提供了稳定、唯一与抗欺骗的可信终端识别能力。
3. har和hsp
鸿蒙lib库分为har和hsp,har包类似正常的lib库,但是如果存在多终端发布可能会重复引用导致包体变大;hsp包为项目内可以共享的lib库,可以提高代码、资源的可重用性和可维护性。
实践过程中发现对外提供lib库时如使用hsp须包名,版本与宿主App保持一致,否则会出现安装失败问题。通过实践总结如下:
(1)对外提供sdk要使用har包;
(2)项目内部共享的基础库使用hsp包。
4. sdk依赖
复杂的App项目基本上都会采用分模块管理,不可避免会出现多个模块依赖同一基础库的现象。基础库升级时所有依赖此基础库的模块均需升级,此时非常容易出现个别模块遗漏升级而导致库冲突。
建议统一管理维护sdk依赖,具体操作如下:
(1)将版本信息统一放置在parameter-file.json;
(2)增加冲突解决配置,.ohpmrc中配置resolve_conflict=true
,配置后系统会自动使用最新lib库版本。
五、未来展望
展望未来,我们将深度依托鸿蒙系统的“一次开发、多端部署”核心优势,进一步拓展金融服务边界,构建跨设备、无缝连接的“1+8+N”全场景智慧金融服务生态,将服务延伸至PC、电视、智能手表、智能音箱、平板、穿戴设备、车机、耳机以及更多泛IoT设备(即“N”类设备),实现金融服务在各类智能终端上的全面覆盖与深度融合。银行网点服务侧,我们将结合鸿蒙实况窗技术,实现客户在网点排队取号时,可通过手机或智能手表实时查看排队进度,甚至提前线上完成部分业务预办理,提升服务效率与用户体验。此外,通过对接鸿蒙的意图框架,智能识别用户的信用卡还款需求,自动推送还款提醒,减少逾期风险;同时,基于用户的地理位置等信息,精准推送本地化的金融产品与服务,实现金融服务的个性化与精准化。
来源:juejin.cn/post/7403606017308082226
11 个 JavaScript 杀手脚本,用于自动执行日常任务
作者:js每日一题
今天这篇文章,我将分享我使用收藏的 11 个 JavaScript 脚本,它们可以帮助您自动化日常工作的各个方面。
1. 自动文件备份
担心丢失重要文件?此脚本将文件从一个目录复制到备份文件夹,确保您始终保存最新版本。
const fs = require('fs');const path = require('path');
function backupFiles(sourceFolder, backupFolder) { fs.readdir(sourceFolder, (err, files) => { if (err) throw err; files.forEach((file) => { const sourcePath = path.join(sourceFolder, file); const backupPath = path.join(backupFolder, file); fs.copyFile(sourcePath, backupPath, (err) => { if (err) throw err; console.log(`Backed up ${file}`); }); }); });}const source = '/path/to/important/files';const backup = '/path/to/backup/folder';backupFiles(source, backup);
提示:将其作为 cron 作业运行
2. 发送预定电子邮件
需要稍后发送电子邮件但又担心忘记?此脚本允许您使用 Node.js 安排电子邮件。
const nodemailer = require('nodemailer');
function sendScheduledEmail(toEmail, subject, body, sendTime) { const delay = sendTime - Date.now(); setTimeout(() => { let transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: 'your_email@gmail.com', pass: 'your_password', // Consider using environment variables for security }, }); let mailOptions = { from: 'your_email@gmail.com', to: toEmail, subject: subject, text: body, }; transporter.sendMail(mailOptions, function (error, info) { if (error) { console.log(error); } else { console.log('Email sent: ' + info.response); } }); }, delay);}// Schedule email for 10 seconds from nowconst futureTime = Date.now() + 10000;sendScheduledEmail('recipient@example.com', 'Hello!', 'This is a scheduled email.', futureTime);
注意:传递您自己的凭据
3. 监控目录的更改
是否曾经想跟踪文件的历史记录。这可以帮助您实时跟踪它。
const fs = require('fs');
function monitorFolder(pathToWatch) { fs.watch(pathToWatch, (eventType, filename) => { if (filename) { console.log(`${eventType} on file: ${filename}`); } else { console.log('filename not provided'); } });}monitorFolder('/path/to/watch');
用例:非常适合关注共享文件夹或监控开发目录中的变化。
4. 将图像转换为 PDF
需要将多幅图像编译成一个 PDF?此脚本使用 pdfkit 库即可完成此操作。
const fs = require('fs');const PDFDocument = require('pdfkit');
function imagesToPDF(imageFolder, outputPDF) { const doc = new PDFDocument(); const writeStream = fs.createWriteStream(outputPDF); doc.pipe(writeStream); fs.readdir(imageFolder, (err, files) => { if (err) throw err; files .filter((file) => /.(jpg|jpeg|png)$/i.test(file)) .forEach((file, index) => { const imagePath = `${imageFolder}/${file}`; if (index !== 0) doc.addPage(); doc.image(imagePath, { fit: [500, 700], align: 'center', valign: 'center', }); }); doc.end(); writeStream.on('finish', () => { console.log(`PDF created: ${outputPDF}`); }); });}imagesToPDF('/path/to/images', 'output.pdf');
提示:非常适合编辑扫描文档或创建相册。
5. 桌面通知提醒
再也不会错过任何约会。此脚本会在指定时间向您发送桌面通知。
const notifier = require('node-notifier');
function desktopNotifier(title, message, notificationTime) { const delay = notificationTime - Date.now(); setTimeout(() => { notifier.notify({ title: title, message: message, sound: true, // Only Notification Center or Windows Toasters }); console.log('Notification sent!'); }, delay);}// Notify after 15 secondsconst futureTime = Date.now() + 15000;desktopNotifier('Meeting Reminder', 'Team meeting at 3 PM.', futureTime);
注意:您需要先安装此包:npm install node-notifier。
6. 自动清理旧文件
此脚本会删除超过 n 天的文件。
const fs = require('fs');const path = require('path');
function cleanOldFiles(folder, days) { const now = Date.now(); const cutoff = now - days * 24 * 60 * 60 * 1000; fs.readdir(folder, (err, files) => { if (err) throw err; files.forEach((file) => { const filePath = path.join(folder, file); fs.stat(filePath, (err, stat) => { if (err) throw err; if (stat.mtime.getTime() < cutoff) { fs.unlink(filePath, (err) => { if (err) throw err; console.log(`Deleted ${file}`); }); } }); }); });}cleanOldFiles('/path/to/old/files', 30);
警告:请务必仔细检查文件夹路径,以避免删除重要文件。
7. 在语言之间翻译文本文件
需要快速翻译文本文件?此脚本使用 API 在语言之间翻译文件。
const fs = require('fs');const axios = require('axios');
async function translateText(text, targetLanguage) { const response = await axios.post('https://libretranslate.de/translate', { q: text, source: 'en', target: targetLanguage, format: 'text', }); return response.data.translatedText;}(async () => { const originalText = fs.readFileSync('original.txt', 'utf8'); const translatedText = await translateText(originalText, 'es'); fs.writeFileSync('translated.txt', translatedText); console.log('Translation completed.');})();
注意:这使用了 LibreTranslate API,对于小型项目是免费的。
8. 将多个 PDF 合并为一个
轻松将多个 PDF 文档合并为一个文件。
const fs = require('fs');const PDFMerger = require('pdf-merger-js');
async function mergePDFs(pdfFolder, outputPDF) { const merger = new PDFMerger(); const files = fs.readdirSync(pdfFolder).filter((file) => file.endsWith('.pdf')); for (const file of files) { await merger.add(path.join(pdfFolder, file)); } await merger.save(outputPDF); console.log(`Merged PDFs int0 ${outputPDF}`);}mergePDFs('/path/to/pdfs', 'merged_document.pdf');
应用程序:用于将报告、发票或任何您想要的 PDF 合并到一个地方。
9. 批量重命名文件
需要重命名一批文件吗?此脚本根据模式重命名文件。
const fs = require('fs');const path = require('path');
function batchRename(folder, prefix) { fs.readdir(folder, (err, files) => { if (err) throw err; files.forEach((file, index) => { const ext = path.extname(file); const oldPath = path.join(folder, file); const newPath = path.join(folder, `${prefix}_${String(index).padStart(3, '0')}${ext}`); fs.rename(oldPath, newPath, (err) => { if (err) throw err; console.log(`Renamed ${file} to ${path.basename(newPath)}`); }); }); });}batchRename('/path/to/files', 'image');
提示:padStart(3, '0') 函数用零填充数字(例如,001,002),这有助于排序。
10. 抓取天气数据
通过从天气 API 抓取数据来了解最新天气情况。
const axios = require('axios');
async function getWeather(city) { const apiKey = 'your_openweathermap_api_key'; const response = await axios.get( `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}&units=metric` ); const data = response.data; console.log(`Current weather in ${city}: ${data.weather[0].description}, ${data.main.temp}°C`);}getWeather('New York');
注意:您需要在 OpenWeatherMap 注册一个免费的 API 密钥。
11. 生成随机引语
此脚本获取并显示随机引语。
const axios = require('axios');
async function getRandomQuote() { const response = await axios.get('https://api.quotable.io/random'); const data = response.data; console.log(`"${data.content}" \n- ${data.author}`);}getRandomQuote();
最后,感谢您一直阅读到最后!希望今天内容能够帮助到你,如果你喜欢此内容的话,也请分享给你的小伙伴,也许能够帮助到他们。
来源:juejin.cn/post/7502855221241888805
不用 js实现渐变、虚线、跑马灯、可伸缩边框
最近遇到个需求,要求实现一个渐变色的边框,并且是虚线的,同时还要有动画。
有的朋友可能看到这里就要开骂了,估计要提刀找设计和产品怼回去了。
但其实我是可以理解的,因为这种花哨的边框想要用在一个类似于魔法框的地方,框住一个地方,然后交给 ai 处理。这样的交互设计可以很好的体现科技感,并且我也想尝试一下,就接了这个需求。
单看几个条件都好处理,css 已经支持了 border-image。
再不济用伪元素遮盖一下,clip-path镂空也可以
甚至我看到很多网站是直接放个视频就完了
但是我这次的需求最重要的是虚线,这就不好处理了。因为设置了边框为虚线后会忽略掉 border-image。
其实这个问题看起来很难,做起来也确实难。我搜到了张鑫旭大佬多年前的文章,就是专门讲这件事的
http://www.zhangxinxu.com/wordpress/2…
看完之后我受益匪浅,虽然我不能用他的方案(因为他的方案中,虚线是假的,样式会和浏览器有差异)
我尝试了很多方案,mask、clip-path、背景图等等,效果都不好。
绝望之际我想到了一个救星svg
div 做不到的事情,我 svg 来做。svg 可以设置 stroke,可以设置 fill,可以设置渐变色,渐变色还可以做动画。简直就是完美的符合需求
先写个空标签上去
<style>
.rect{
width: 100px;
height: 100px;
}
</style>
<div class='rect'>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
</svg>
</div>
因为我需要 svg 尺寸跟随父容器变化,所以就不写 viewBox 了,直接设置宽高 100%。同时在里面画一个矩形,也是宽高 100%。
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width='100%' height='100%'>
<rect width="100%" height="100%"></rect>
</svg>
现在长这样
接下来给 rect 设置填充和描边,边框宽度为 4px
<rect
fill="transparent"
stroke="red"
stroke-width="4"
width="100%"
height="100%"
></rect>
接下来我们给border 设置为渐变色,需要在 svg 中定义一个渐变,svg 定义渐变色还是很方便的,都是现成标签和属性直接就可以通过 id 取到。
<svg>
...
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="lightcoral" />
<stop offset="50%" stop-color="lightblue" />
<stop offset="100%" stop-color="lightgreen" />
</linearGradient>
</defs>
</svg>
接下来给红色的 stroke 换成渐变色
<rect
fill="transparent"
stroke="url(#gradient)"
stroke-width="4"
width="100%"
height="100%"
></rect>
接下通过 stroke-dasharray
来设置虚线边框
mdn 上关于 dasharry的介绍在这里 developer.mozilla.org/zh-CN/docs/…
我给 rect 设置 dasharray 为 5,5
<rect
fill="transparent"
stroke="url(#gradient)"
stroke-dasharray="5,5"
stroke-width="4"
width="100%"
height="100%"
></rect>
这样渐变虚线边框就成了
接下来处理动画效果
动画分两种
- 线动,背景色不动
- 线不动,背景色动
这两种效果我都实现了
首先展示线动,背景色不动的情况
这种情况只要能想办法让虚线产生偏移就可以,于是我搜了一下,这不巧了吗,正好有个属性叫 stroke-dashoffset
于是就可以通过 css 动画来修改偏移量
<style>
.dashmove {
animation: dashmove 1s linear infinite;
}
@keyframes dashmove {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 10;
}
}
</style>
<rect class="dashmove" .... ></rect>
大功告成
接下来处理第二种情况,线不动,背景动
这种情况就更简单了,因为 svg 本身就支持动画
我们只需要在渐变色中增加一个animateTransform标签
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
...
<animateTransform
attributeName="gradientTransform"
type="rotat
from="0 0.5 0.5"
to="360 0.5 0.5"
dur="1s"
repeatCount="indefinite"
/>
</linearGradient>
接下来看一下拖拽的效果,这个很重要,因为我们不希望随着容器比例变化,会让边框宽度也变化。
给容器元素加上这三个属性,这个 div 就变成了可拖拽缩放的
.rect{
// ...
resize: both;
position: relative;
overflow: auto;
}
看下效果
完美 🎉🎉🎉
在这里查看完整在线 demo stackblitz.com/edit/stackb…
来源:juejin.cn/post/7502127751572406323
鸿蒙UI通用代码几种抽离方法
对于做APP的UI,免不了会写大量的重复布局,重复UI页面。此时对于将重复的UI控件抽离出来封装为通用组件来进行优化很是重要。
本文重点分析鸿蒙几种UI处理上,如何抽离通用方法来进行UI的复用。重点对比@Style,@Extend, AttributeModifier, @Builder和 struct 这五种方法的区别和使用场景。
Styles装饰器
学过Android开发的小伙伴知道Android中有样式Style概念,我们定义好一个样式,那么就可以在各个组件中使用。从而保持每个组件有一样的属性。
同理,鸿蒙中也可以使用样式。比如我们UI上的按钮具有相同的宽,高,背景色和边角距。那么我们就可以定义一个Style,每次定义按钮时候,只需要将Style赋给按钮就可以实现该属性,从而避免重复代码的书写。
- 代码说明
如图,在当前页面内定义一个方法,使用装饰器Styles修饰,修饰后,方法内部就可以直接通过 .属性 的方式来定义属性了。方法定义完后,下方button里可以直接使用该方法。虽然我这里方法命名为commonButton,但是实际上所有基础控件都可以使用该方法使用里边的属性,比如下方的Text组件。
- Style特点
- 对于定义的方法,无法使用export修饰。
这也就意味着,我们抽离的通用属性,只能在当前页面内的组件上使用,换个页面就不可以了,无法做到全局所有页面通用。 - 对于定义的方法,只能定义组件的通用属性。
比如宽高,背景色等。对于一些控件特有属性是无法定义的。比如Select组件的selectedOptionFont特有属性无法定义。 - 方法不支持传递入参。
意味着该样式无法做到动态修改,只要定义好就无法修改。比如定义好宽高为30,而某个组件宽要求为40,其他属性都不变,那这个方法也没法用了。 - 方法为组件通用属性,故所有组件都可以引用方法。
Extend装饰器
对于Styles装饰器的第2点限制,鸿蒙推出了解决方案,那就是使用@Extend装饰器。
Extend装饰器需要我们在使用时候指定定义哪个组件的属性,是专门抽离指定组件的。
- 代码说明
Extend要求必须定义方法为在当前文件的全局定义,且也不能够export,同时定义时候需要指定是针对哪个控件。如图指定了控件Select,然后就可以指定Select的专有属性了。
- Extend特点
- 方法不支持export。
和Styles一样,无法真正做到为所有页面抽离出都可用的属性方法。 - 方法只能定义为当前页面内的全局方法。
一定程度上全局方法存在引用GlobalThis,具体副作用未知。 - 方法需要指定控件,其他控件无法使用,只能对专有控件做到了抽离
- 方法可以传入参。
相比Styles, 可以在其他属性不变的情况下,只修改其中的部分属性。
AttributeModifier
对于上述两个装饰器都存在一个相同的限制,就是无法做到全局所有文件都可以公用。
AttributeModifier的出现可以变相的解决这个问题。AttributeModifier本意是用于自定义控件中设置属性用的。但是我们在这里也可以通过这个机制,来实现全局所有文件中控件均可通用的属性。
- 代码说明
该Modifier只能针对专用控件,比如我要抽离一个通用的TextInput,那么我可以如上图所定义。
需要实现一个接口 AttributeModifier,接口泛型定义和我们想要给哪个控件使用有关,比如我们想给TextInput使用,那么泛型就是 TextInputAttribute,如果给Column使用,那么泛型就是ColumnAttribute,以此类推。
在该接口的实现方法中,定义控件的属性。
- 布局中使用
- 自定义属性
我们还可以自定义部分属性,只需要修改TextInputAttribute,例如我们想自定义字体大小。可以定义变量。
- 使用
- AttributeModifier特点
- 可以全局给所有页面中的控件使用
- 可以自定义任何控件中的属性,包括特有属性
- 可以通过修改代码做成链式调用
- 该方法需要new对象,比较笨重,需要书写较多代码
@Builder
上述说的都是针对单独的控件,如果我们想抽离一个通用的布局呢?或者我们的控件就是固定的可以拿来到处使用。
比如我有一个Text,各种属性固定,只是文案不同,那么我使用上述几种都比较麻烦,需要书写较多代码。那么这个时候就可以使用builder了。
- 代码
我们可以在任意需要展示该Text的地方使用,直接调用该方法,对应位置就可以显示出内容了。原理相当于是将方法内的控件代码放到了对应的位置上。
- 使用
- @Builder特点
- 定义好方法后,需要拿Builder装饰器修饰,可以在任何一个页面内调用方法使用。
- 可以通过方法传递入参
- 无法通过方法拿到控件对象,只能在方法里操作控件属性
- 除了单一控件,还可以定义布局,布局中存在多个控件的情况
- 轻量级
- 方法即拿即用,代码量少
struct
有时候,我们可能页面中存在大量如下UI:
对于这种UI,我们完全可以抽离出为一个控件。然后我们页面需要展示的地方,直接调用该控件,设置标题,按钮文案等就可以简化了。
我们可能想到使用builder来定义,但是builder只能写纯UI代码,这里还涉及到用户输入的内容,如何在点击按钮时候传过去。所以builder就无法使用了,这个时候就可以用struct封装了。
- 代码
@Component
export struct InputNumberItemWithButton {
label: string = "标题"
buttonClick: (v: number) => void = () => {
}
buttonLabel: string = "设置"
inputPlaceholder: string = "我是提示语"
inputId: string = this.label
parentWidth: string = '100%'
private value: number = 0
build() {
RelativeContainer() {
Text(this.label)
.attributeModifier(Modifier.textLabel())
.id('label1')
.alignRules({
left: { anchor: '__container__', align: HorizontalAlign.Start },
top: { anchor: '__container__', align: VerticalAlign.Top },
bottom: { anchor: '__container__', align: VerticalAlign.Bottom }
})
.margin({ left: 2 })
TextInput({ placeholder: this.inputPlaceholder })
.onChange((value: string) => {
this.value = Number.parseInt(value) ?? 0
})
.type(InputType.Number)
.id(this.inputId)
.height(30)
.placeholderFont({ size: 10 })
.fontSize(CommonStyle.INPUT_TEXT_SIZE)
.borderRadius(4)
.alignRules({
right: { anchor: 'button1', align: HorizontalAlign.Start },
left: { anchor: 'label1', align: HorizontalAlign.End },
top: { anchor: '__container__', align: VerticalAlign.Top },
bottom: { anchor: '__container__', align: VerticalAlign.Bottom }
})
.margin({ left: 6, right: 6 })
Button(this.buttonLabel)
.attributeModifier(SuBaoSmallButtonModifier.create())
.onClick(() => {
this.buttonClick(this.value)
})
.id('button1')
.alignRules({
right: { anchor: '__container__', align: HorizontalAlign.End },
top: { anchor: '__container__', align: VerticalAlign.Top },
bottom: { anchor: '__container__', align: VerticalAlign.Bottom }
})
.margin({ right: 2 })
}
.width(this.parentWidth)
.height(40)
.padding({
left: 5,
right: 5,
top: 2,
bottom: 2
})
.borderRadius(4)
}
}
该struct中通过维护一个变量value 来保存用户输入的数字,然后在用户点击按钮时候传给点击事件方法,交给调用者调用。
- 使用
点击设置按钮,点击事件触发,a直接赋值。
- struct特点
- 可以封装复杂组件,自定义组件
- 可以维护变量存储用户输入输出
- 可以所有页面全局使用
- 可以自定义属性
- 无法链式设置属性
对比各个使用场景
实际编程中,一般都是混合相互配合使用,没必要单独硬使用哪一个。
- style
可以用来定义一些通用属性,比如背景色,边角据等 - Extend
对于页面中一些特殊的控件,用的地方较多时候,可以抽离方法 - AttributeModifier
如果Extend无法满足,那么选择这个 - Builder
对于布局控件的属性变化不大,但是用的地方多时候使用,比如定义一个分割线。 - struct
涉及到用户输入输出时候,相关控件可以抽离封装,避免页面内上方定义太多变量,不好维护。
来源:juejin.cn/post/7374293974577692706
Promise 引入全新 API!效率提升 300%!
来源:前端开发爱好者
在 JavaScript 的世界里,Promise 一直是处理异步操作的神器。
而现在,随着 ES2025 的发布,Promise 又迎来了一个超实用的新成员——Promise.try()
!
这个新方法简直是对异步编程的一次 “革命” ,让我们来看看它是怎么让代码变得更简单、更优雅的!
什么是 Promise.try()
?
简单来说,Promise.try()
是一个静态方法,它能把任何函数(同步的、异步的、返回值的、抛异常的)包装成一个 Promise。无论这个函数是同步还是异步,Promise.try()
都能轻松搞定,还能自动捕获同步异常,避免错误遗漏。
语法
Promise.try(func)
Promise.try(func, arg1)
Promise.try(func, arg1, arg2)
Promise.try(func, arg1, arg2, /* …, */ argN)
参数
func
:要包装的函数,可以是同步的,也可以是异步的。arg1
、arg2
、…、argN
:传给func
的参数。
返回值
一个 Promise,可能的状态有:
- 如果
func
同步返回一个值,Promise 就是已兑现的。 - 如果
func
同步抛出一个错误,Promise 就是已拒绝的。 - 如果
func
返回一个 Promise,那就按这个 Promise 的状态来。
为什么需要 Promise.try()
?
在实际开发中,我们经常遇到一种情况:不知道或者不想区分函数是同步还是异步,但又想用 Promise 来处理它。
以前,我们可能会用 Promise.resolve().then(f)
,但这会让同步函数变成异步执行,有点不太理想。
const f = () => console.log('now');
Promise.resolve().then(f);
console.log('next');
// next
// now
上面的代码中,函数 f
是同步的,但用 Promise
包装后,它变成了异步执行。
有没有一种方法,让同步函数同步执行,异步函数异步执行,并且让它们具有统一的 API 呢?
答案是可以的,并且 Promise.try()
就是这个方法!
怎么用 Promise.try()
?
示例 1:处理同步函数
const syncFunction = () => {
console.log('同步函数执行中');
return '同步的结果';
};
Promise.try(syncFunction)
.then(result => console.log(result)) // 输出:同步的结果
.catch(error => console.error(error));
示例 2:处理异步函数
const asyncFunction = () => {
returnnewPromise(resolve => {
setTimeout(() => {
resolve('异步的结果');
}, 1000);
});
};
Promise.try(asyncFunction)
.then(result =>console.log(result)) // 1秒后输出:异步的结果
.catch(error =>console.error(error));
示例 3:处理可能抛出异常的函数
const errorFunction = () => {
throw new Error('同步的错误');
};
Promise.try(errorFunction)
.then(result => console.log(result))
.catch(error => console.error(error.message)); // 输出:同步的错误
Promise.try()
的优势
- 统一处理同步和异步函数:不管函数是同步还是异步,
Promise.try()
都能轻松搞定,代码更简洁。 - 异常处理:自动捕获同步异常,错误处理更直观,避免遗漏。
- 代码简洁:相比传统方法,
Promise.try()
让代码更易读易维护。
实际应用场景
场景 1:统一处理 API 请求
function fetchData(url) {
return Promise.try(() => fetch(url))
.then(response => response.json())
.catch(error => console.error('请求失败:', error));
}
fetchData('https://api.example.com/data')
.then(data => console.log('数据:', data));
场景 2:混合同步和异步操作
const syncTask = () => '同步任务完成';
const asyncTask = () => new Promise(resolve => setTimeout(() => resolve('异步任务完成'), 1000));
Promise.try(syncTask)
.then(result => console.log(result)) // 输出:同步任务完成
.then(() => Promise.try(asyncTask))
.then(result => console.log(result)) // 1秒后输出:异步任务完成
.catch(error => console.error(error));
场景 3:处理数据库查询
function getUser(userId) {
return Promise.try(() => database.users.get({ id: userId }))
.then(user => user.name)
.catch(error => console.error('数据库查询失败:', error));
}
getUser('123')
.then(name => console.log('用户名称:', name));
场景 4:处理文件读取
function readFile(path) {
return Promise.try(() => fs.readFileSync(path, 'utf8'))
.catch(error => console.error('文件读取失败:', error));
}
readFile('example.txt')
.then(content => console.log('文件内容:', content));
总结
Promise.try()
的引入让异步编程变得更加简单和优雅。
它统一了同步和异步函数的处理方式,简化了错误处理,让代码更易读易维护。
ES2025 的这个新特性,绝对值得你去尝试!快去试试吧,你的代码会变得更清晰、更强大!
来源:juejin.cn/post/7494174524453158949
做Docx预览,一定要做这个神库!!
来源:沉浸式趣谈
只需几行代码,你就能在浏览器中完美预览 Word 文档,甚至连表格样式、页眉页脚都原汁原味地呈现出来。
接下来,给大家分享两个 Docx 预览的库:
docx-preview VS mammoth
docx-preview
和mammoth
是目前最流行的两个 Word 文档预览库,它们各有特色且适用于不同场景。
docx-preview:还原度爆表的选择
安装简单:
npm install docx-preview
基础用法:
import { renderAsync } from 'docx-preview';
// 获取到docx文件的blob或ArrayBuffer后
renderAsync(docData, document.getElementById('container')).then(() => console.log('文档渲染完成!'));
试了试后,这个库渲染出来的效果简直和 Office 打开的一模一样!连段落格式、表格样式、甚至是分页效果,都完美呈现。
mammoth:简洁至上的转换器
mammoth 的思路完全不同,它把 Word 文档转成干净的 HTML:
npm install mammoth
使用也很简单:
import mammoth from 'mammoth';
mammoth.convertToHtml({ arrayBuffer: docxBuffer }).then(result => {
document.getElementById('container').innerHTML = result.value;
console.log('转换成功,但有些警告:', result.messages);
});
转换出来的 HTML 非常干净,只保留了文档的语义结构。
比如,Word 中的"标题 1"样式会变成 HTML 中的
标签。
哪个更适合你?
场景一:做了个简易 Word 预览器
要实现在线预览 Word 文档,且跟 "Word" 长得一模一样。
首选docx-preview
:
import { renderAsync } from'docx-preview';
async functionpreviewDocx(fileUrl) {
try {
// 获取文件
const response = awaitfetch(fileUrl);
const docxBlob = await response.blob();
// 渲染到页面上
const container = document.getElementById('docx-container');
awaitrenderAsync(docxBlob, container, null, {
className: 'docx-viewer',
inWrapper: true,
breakPages: true,
renderHeaders: true,
renderFooters: true,
});
console.log('文档渲染成功!');
} catch (error) {
console.error('渲染文档时出错:', error);
}
}
效果很赞!文档分页显示,目录、页眉页脚、表格边框样式都完美呈现。
不过也有些小坑:
- 文档特别大时,渲染速度会变慢
- 一些复杂的 Word 功能可能显示不完美
场景二:做内容编辑系统
需要让用户上传 Word 文档,然后提取内容进行编辑。
选择mammoth
:
import mammoth from'mammoth';
async functionextractContent(file) {
try {
// 读取文件
const arrayBuffer = await file.arrayBuffer();
// 自定义样式映射
const options = {
styleMap: ["p[style-name='注意事项'] => div.alert-warning", "p[style-name='重要提示'] => div.alert-danger"],
};
const result = await mammoth.convertToHtml({ arrayBuffer }, options);
document.getElementById('content').innerHTML = result.value;
if (result.messages.length > 0) {
console.warn('转换有些小问题:', result.messages);
}
} catch (error) {
console.error('转换文档失败:', error);
}
}
mammoth 的优点在这个场景下完全发挥出来:
- 1. 语义化 HTML:生成干净的 HTML 结构
- 2. 样式映射:可以自定义 Word 样式到 HTML 元素的映射规则
- 3. 轻量转换:处理速度非常快
进阶技巧
docx-preview 的进阶配置
renderAsync(docxBlob, container, styleContainer, {
className: 'custom-docx', // 自定义CSS类名前缀
inWrapper: true, // 是否使用包装容器
ignoreWidth: false, // 是否忽略页面宽度
ignoreHeight: false, // 是否忽略页面高度
breakPages: true, // 是否分页显示
renderHeaders: true, // 是否显示页眉
renderFooters: true, // 是否显示页脚
renderFootnotes: true, // 是否显示脚注
renderEndnotes: true, // 是否显示尾注
renderComments: true, // 是否显示评论
useBase64URL: false, // 使用Base64还是ObjectURL处理资源
});
超实用技巧:如果只想把文档渲染成一整页(不分页),只需设置breakPages: false
!
mammoth 的自定义图片处理
默认情况下,mammoth 会把图片转成 base64 嵌入 HTML。
在大型文档中,这会导致 HTML 特别大。
更好的方案:
const options = {
convertImage: mammoth.images.imgElement(function (image) {
return image.readAsArrayBuffer().then(function (imageBuffer) {
// 创建blob URL而不是base64
const blob = newBlob([imageBuffer], { type: image.contentType });
const url = URL.createObjectURL(blob);
return {
src: url,
alt: '文档图片',
};
});
}),
};
mammoth.convertToHtml({ arrayBuffer: docxBuffer }, options).then(/* ... */);
这样一来,图片以 Blob URL 形式加载,页面性能显著提升!
其他方案对比
说实话,在选择这两个库之前,也有其他解决方案:
微软 Office Online 在线预览
利用微软官方提供的 Office Online Server 或 Microsoft 365 的在线服务,通过嵌入 WebView
或
优点
- • 格式高度还原:支持复杂排版、图表、公式等。
- • 无需本地依赖:纯浏览器端实现。
- • 官方维护:兼容性最好。
折腾一圈,还是docx-preview
和mammoth
这俩兄弟最实用。
它们提供了轻量级的解决方案,仅需几十 KB 就能搞定 Word 预览问题,而且不需要依赖外部服务,完全可以在前端实现。
来源:juejin.cn/post/7493733975779917861
产品小姐姐:地图(谷歌)选点,我还不能自己点?
💡 背景
最近在做海外项目,需要让用户选择一个实际地点——比如设置店铺位置、收货地址、活动举办地等。
我:不就是 uni.getLocation(object) 嘛,可惜海外项目用不了高德地图和百度地图,只能转向谷歌地图。
在@googlemaps/js-api-loader和vue3-google-map一顿折磨之后,不知道是不是使用方式错了,谷歌地图只在h5上显示,真机(包括自定义基座)都显示不了地图。无奈,只能转向WebView,至此,开始手撕谷歌地图: 地图选点 + 搜索地址 + 点击地图选点 + 经纬度回传
🎬 场景设定:选房子
某天产品说:“用户能搜地址,也能点地图,最后把这些地点存起来显示在地图。”
我听着简单,于是点开地图,灵光一闪:这不就是选房的逻辑吗?
- 用户可以搜地段(搜索框)
- 也可以瞎逛看到喜欢的(点击地图)
- 最后点个确定,告诉中介(确认按钮)
我:“你疯啦?这是太平洋中间。”
产品:“不是,这是用户自由。”
🧱 核心结构分析
📦 页面骨架
<div id="map"></div>
<div id="overlay-controls">
<input id="search-input" ... />
<div id="confirm-btn">确定</div>
</div>
<script type="text/javascript" src="https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js"></script>
<script src="https://maps.googleapis.com/maps/api/js?key=你的key&callback=initMap" async defer></script>
<script>
这是一个标准的地图 + 控制浮层结构。我们用一个 #map
占据全屏,再通过 position: absolute
让搜索框和按钮漂浮在上面。(ps:注意必须引入uni.webview才能进行通讯)
🧠 方法逐个看
1. initMap
:地图的灵魂觉醒
function initMap() { ... }
- 调用时机:Google Maps 的
callback
会自动触发 - 作用:初始化地图、绑定事件、准备控件
2. 获取定位:我在哪我是谁
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(...)
}
- 成功:把你真实的位置显示出来
- 失败:退而求其次用旧金山
3. 搜索地址:让用户自己找方向
const autocomplete = new google.maps.places.Autocomplete(input);
autocomplete.addListener("place_changed", () => {
const place = autocomplete.getPlace();
...
});
- 功能:用 Google 提供的地址搜索建议
- 高级点:可以定位到建筑物级别的精度
- 产品:用户脑子里比你更清楚他想去哪
4. 点地图选点:给随性的人自由
map.addListener("click", (e) => {
const lat = e.latLng.lat();
const lng = e.latLng.lng();
...
});
- 功能:用户随手一点,就能选中那个点
- 技术点:用
Geocoder
反解析经纬度 ➜ 地址 - 实用性:解决“我不知道地址叫什么”的痛点,且可切换卫星实景图像选点
就像: 当年你去面试,不知道公司叫什么,只知道“拐角有个便利店”。
5. setLocation:标记我心之所向
function setLocation(lat, lng, address) {
selectedLatLng = { lat, lng };
selectedAddress = address;
...
}
- 核心职责:更新选择结果,设置 marker
- 重复点击自动替换 marker,保持页面整洁
- UI 响应式体验的小心机,细节满满
哲理时间: 你不能同时站在两个地方,虽然marker可以,但是此处marker不做分布点,只作为当前点击地点。
6. confirm-btn:确定这就是你的人生目标吗?
document.getElementById("confirm-btn").addEventListener("click", () => {
if (!selectedLatLng) {
alert("请先选择地点");
return;
}
uni.postMessage({ data: { ... } });
uni.navigateBack({ delta: 1 });
});
- 检查用户是否真的选点了
- 用
uni.postMessage
把选中的地址、经纬度送回 uniapp 主体页面 - 然后自动关闭 WebView,返回主流程
产品视角: 用户选完东西,你就别啰嗦了,自己退出。
可查看卫星实景图像,点击地图选点
点击地图拿到地点数据就可以继续业务处理啦~
🎁 彩蛋动画:CSS Loading
<div class="loader" id="loader"></div>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
加载的时候出现个小旋转圈圈,用户等得不烦,体验感 +1。
这就像爱情:等一等,说不定就没了。
✅ 功能总结
功能 | 实现方式 |
---|---|
地图显示 | Google Maps JS SDK |
获取当前位置 | navigator.geolocation |
搜索地点 | google.maps.places.Autocomplete |
点击地图选点 | map.addListener("click") + Geocoder |
回传经纬度 | uni.postMessage |
用户体验优化 | marker 替换、加载动画 |
🧘 写在最后
这个地图选点组件,看似只是点点点,但背后涉及用户体验、API 使用、移动端交互的多种协作。本文只写了大概方法思路,具体实现看具体业务需求。
下次再见!🌈
来源:juejin.cn/post/7501649258279845939
Vue动态弹窗(Dialog)新境界:告别繁琐,拥抱优雅!🎉
写在开头
嘿,各位好呀!😀
今是2025年04月30日,明天就是五一假期了,激动的心从早上就一直没有沉静过,午休的时候闭着眼半小时硬是没睡着,哎,这班是一点也上不下去。
好!说回正题,本次要分享的是关于如何在Vue中比较优雅的调用弹窗的过程,请诸君按需食用哈。
需求背景
最近,小编在捣鼓一个和低代码拖动交互类似的业务,说到低代码,大家肯定都不陌生吧❓像低代码表单、低代码图表平台等,用户可以通过简单的拖拽操作,像搭积木一样,快速"拼"出一个功能完善的表单页面,或者酷炫的数据可视化大屏。
而在这些低代码平台中,配置组件属性的交互方式通常有两种主流玩法:
其一,三栏式布局,左边是组件列表,中间是画布/预览区,右边是属性配置面板。选中中间画布的某个组件,右侧面板就自动显示它的配置项,如下:
其二,弹窗式配置,同样从左侧拖拽组件到画布,但选中组件后,通常会看到一个"设置"或"编辑"按钮。点击这个按钮,Duang~ ✨ 弹出一个专门的配置窗口 (Dialog),让你在里面集中完成所有设置。
这两种交互各有千秋,不评判好坏哈,反正合适自己业务场景的才是最好的。
然,今天咱们重点聚焦第二种:点击按钮弹出 Dialog
进行配置的场景。
这种方式在很多场景下也很常见,比如配置项特别多、需要更沉浸式的设置体验时。
但问题也随之而来:如果平台支持的组件越来越多,这里咱们假设是低代码图表场景,如柱状图、折线图、饼图、地图、文本、图片...等等,每个组件都需要一个独立的配置弹窗...🤔,那么,我们应该如何设计一套优雅、可扩展、易维护的代码架构来管理这些层出不穷的 Dialog
呢?🤔
结构设计
万事开头难,尤其是在做一些稍微带点设计或架构意味的事情时,切忌盲目上手。心里得先有个谱,想清楚大致方向,否则等到后面业务需求像潮水般涌来,迭代压力陡增时,你就会深刻体会到早期设计不佳带来的痛苦了(别问小编是怎么知道的...😭)。
当然,如果你已是经验丰富的老司机,那就当我没说哈。😂
面对"组件点击按钮弹出配置框"这个需求,最开始,最直观的想法可能就是:一个组件配一个专属的 Dialog.vue
文件,相互独立,互不影响,挺好不是❓
比如,咱当前有柱状图、折线图、饼图三个组件,那么它们的目录结构可能是这样子的:
src/
├── components/
│ ├── BarChart/
│ │ ├── Drag.vue # 组件的拖动视图
│ │ ├── Dialog.vue # 组件的配置弹窗
│ │ └── index.js # 组件的Model
│ ├── LineChart/
│ │ ├── Drag.vue # 组件的拖动视图
│ │ ├── Dialog.vue # 组件的配置弹窗
│ │ └── index.js # 组件的Model
│ ├── PieChart/
│ │ ├── Drag.vue # 组件的拖动视图
│ │ ├── Dialog.vue # 组件的配置弹窗
│ │ └── index.js # 组件的Model
└── App.vue # 入口
咱们不详说其他文件中的代码情况,仅关注每个组件中 Dialog.vue
文件的代码要如何写❓
可能大概是这样:
<template>
<el-dialog :modelValue="modelValue">
<div>内容....</div>
</el-dialog>
</template>
<script>
defineProps({
modelValue: Boolean,
});
</script>
小编这里使用 Element-Plus 的
el-dialog
组件作为案例演示。
然后,为了在页面上渲染这些不同组件的 Dialog.vue
,最笨的方法可能是在父组件里面用 v-if/v-else-if
来判断, 或者高级一点使用 <component :is="currentDialog">
再配合一堆 import
来动态加载渲染。父组件需要维护哪个弹窗应该显示的状态,以及负责传递数据和接收结果,逻辑很快变得复杂且难以维护。
在项目初期,组件类型少的时候,这种方式确实能跑通,没有问题❗
你就说它能不能跑吧,就算它不能跑,你能跑不就行😋,项目和你总有一个能跑的。
但随着业务不断迭代,支持的组件类型越来越多,这种"各自为战"的模式很快就暴露出了诸多问题,其中有两个问题比较尖锐:
- 缺乏统一控制📝:如果想给所有弹窗统一调整弹窗配置、或者添加一个水印、或者调整一下默认样式、或者增加一个通用的"重置"按钮,怎么办?只能去每个
Dialog.vue
文件里手动修改,效率低下不说,还极易遗漏或出错。 - 代码冗余严重📜:每个
Dialog.vue
文件里,关于弹窗的显示/隐藏逻辑、确认/取消按钮的处理、与 Element Plus (或其他 UI 库) ElDialog 组件的交互代码,几乎都是大同小异的模板代码,写到后面简直是精神污染。(这里手动Q一下我同事🔨)
总之,随着项目的迭代,这种最初看似简单的结构,维护成本越来越高,每次增加或修改一个组件的配置弹窗都成了一种"折磨"。
那么,要如何重新来设计这个架构呢❓
小编采用的是基于动态创建和静态方法关联的架构,其架构的核心理念就是:将通用的弹窗逻辑(创建、销毁、交互)抽离出来,让每个组件的配置面板(Panel)只专注于自身的配置项 UI 视图和数据处理逻辑 ,从而实现高内聚、低耦合、易扩展的目标。
先来瞅瞅目录结构的最终情况👇:
src/
├── components/
│ ├── BarChart/
│ │ ├── Dialog/
│ │ | ├── index.js # Dialog 组件的入口
│ │ | ├── Panel.vue # Dialog 组件UI视图
│ │ ├── Drag.vue
│ │ └── index.js
│ ├── LineChart/
│ │ ├── Dialog/
│ │ | ├── index.js # Dialog 组件的入口
│ │ | ├── Panel.vue # Dialog 组件UI视图
│ │ ├── Drag.vue
│ │ └── index.js
│ ├── PieChart/
│ │ ├── Dialog/
│ │ | ├── index.js # Dialog 组件的入口
│ │ | ├── Panel.vue # Dialog 组件UI视图
│ │ ├── Drag.vue
│ │ └── index.js
│ ├── BaseDialog.vue
│ └── index.js
├── utils/
│ ├── BaseControl.js
│ └── dialog.js
└── App.vue # 入口
关键变动是 Dialog.vue
变成了 Dialog/index.js
与 Dialog/Panel.vue
,它们俩的作用:
Panel.vue
:负责"长什么样"和"填什么数据" 。index.js
:负责"怎么被调用"和"调用时带什么默认配置",并将Panel.vue
包装后提供给外部使用。
具体实现
接下来,咱们就详细拆解一下这套新架构的设计具体代码实现过程。👇
但为了更好的讲述关键代码的实现,咱们不管拖动那块逻辑,仅通过点击按钮简单的来模拟,效果如下:

本次小编是新建了一个 Vue3 的项目并且安装了 ElementPlus 进行了全局引入,基础项目环境就这样。
然后,从入口出发(App.vue
):
<template>
<el-button type="primary" v-for="type in componentList" :key="type" @click="openDialog(type)">
{{ type }}
</el-button>
</template>
<script setup>
import { ElButton } from "element-plus";
import { componentMap } from "./components"; // 引入组件映射
/** @name 实例化所有组件 **/
const componentInstanceMap = Object.keys(componentMap).reduce((pre, key) => {
const instance = new componentMap[key]();
pre[key] = instance;
return pre;
}, {});
/** @name 打开组件弹窗 **/
async function openDialog(type) {
const component = await componentMap[type].DialogComponent.create(
{ type },
componentInstanceMap[type]
);
console.log("component", component);
}
</script>
统一管理所有组件导出文件(components/index.js
):
import PieChart from "./PieChart";
import BarChart from "./BarChart";
import LineChart from "./LineChart";
export const componentMap = {
[PieChart.type]: PieChart,
[BarChart.type]: BarChart,
[LineChart.type]: LineChart,
};
/** @typedef { keyof componentMap } ComponentType */
组件入口文件(components/PieChart/index.js
):
import BaseControl from "../../utils/BaseControl";
import Drag from "./Drag.vue";
import Dialog from "./Dialog";
class Component extends BaseControl {
static type = "barChart";
label = "柱状图";
icon = "bar-chart";
getDialogDataDefault() {
return {
title: { text: "柱状图" },
tooltip: { trigger: "axis" },
};
}
static DragComponent = Drag;
static DialogComponent = Dialog;
}
export default Component;
该文件用于集中管理组件的核心数据结构与统一的业务逻辑。
咱们以柱状图为例哈。📊
所有组件的基类文件(utils/BaseControl.js
):
/** @typedef { import('vue').Component|import('vue').ConcreteComponent } VueConstructor */
export default class BaseControl {
/** @name 组件唯一标识 **/
type = "baseControl";
/** @name 组件label **/
label = "未知组件";
/** @name 组件高度 **/
height = "110px";
constructor() {
if (this.constructor.type) {
this.type = this.constructor.type;
}
}
/**
* @name 拖动组件
* @type { VueConstructor | null }
*/
static DragComponent = null;
/**
* @name 弹窗组件
* @type { VueConstructor | null }
*/
static DialogComponent = null;
dialog = {};
/**
* @name 用于获取Dialog组件的默认数据
* @returns {Dialog} 默认数据
*/
getDialogDataDefault() {
return {};
}
}
该文件是所有组件的"基石"🏛️,每个具体的图表组件都继承自 BaseControl
类,并在该基础上定义自己特有的信息和逻辑。
组件的拖动视图组件(Drag.vue
),这个可以先随便整一个,暂时用不上:
<template>
<div>某某组件的拖动视图组件</div>
</template>
Dialog 组件的入口文件(components/BarChart/Dialog/index.js
):
import Panel from "./Panel.vue"; // Dialog 的 UI 视图组件
import { dialogWithComponent } from "../../../utils/dialog.js";
/**
* @name 静态方法,渲染Dialog组件,并且可在此处自定义dialog组件的props
* @param {{ component: object, instance: object, componentDataAll: Array<object> }} contentProps 组件数据
* @returns {Promise<any>}
*/
Panel.create = async (panelProps = {}) => {
return dialogWithComponent((render) => render(Panel, panelProps), {
title: panelProps.label,
width: "400px",
});
};
export default Panel;
该文件导入真正的 UI 视图面板(Panel.vue
),然后给组件挂载了一个静态 create
方法。这个 create
方法用于动态创建 Dialog 组件,它内部调用 dialogWithComponent
方法,并可以在此处预设一些该 Dialog 组件特有的配置(如默认标题、宽度)。
Dialog 组件的 Panel.vue
文件:
<template>
<h1>柱状图的配置</h1>
</template>
<script setup>
defineExpose({
async getValue() {
await new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
return { type: "barChart" };
}
})
</script>
该组件仅放置柱状图特有的配置信息,并且不需要管弹窗自身的逻辑行为,很干净很专注😎。还有,它内部必须对外提供一个 getValue
方法❗用于在用户点击确认时调用,以获取最终的配置数据。
核心工具函数(utils/dialog.js
)文件 :
import { createApp, h, ref } from "vue";
import { ElDialog, ElMessage } from "element-plus";
import BaseDialog from "../components/BaseDialog.vue";
/**
* @name 协助统一创建dialog组件,并且进行挂载、销毁、上报
* @param {import('vue').Component|Function} ContentComponent 渲染的组件
* @param {import('element-plus').dialogProps} dialogProps dialog组件的props
* @returns {Promise<any>}
*/
export function dialogWithComponent(ContentComponent, dialogProps = {}) {
return new Promise((resolve) => {
/** @name 挂载容器 */
const container = document.createElement("div");
document.body.appendChild(container);
/** @name dialog组件实例 */
let vm = null;
/** @name dialog组件loading */
let loading = ref(false);
const dialogRef = ref(null);
const contentRef = ref(null);
const unmount = () => {
if (vm) {
vm.unmount();
vm = null;
}
document.body.removeChild(container);
};
const confirm = async () => {
let result = {};
const instance = contentRef.value;
if (instance && instance.getValue) {
loading.value = true;
try {
result = await instance.getValue();
} catch (error) {
typeof error === "string" && ElMessage.error(error);
loading.value = false;
return;
}
loading.value = false;
}
unmount();
resolve(result);
};
// 创建dialog组件实例
vm = createApp({
render() {
return h(
BaseDialog,
{
ref: dialogRef,
modelValue: true,
loading: loading.value,
onDialogConfirm() {
confirm();
},
onDialogCancel() {
unmount();
},
...dialogProps,
},
{
default: () => createVNode(h, ContentComponent, contentRef),
},
);
},
});
// 挂载dialog组件
vm.mount(container);
});
}
/**
* @name 创建一个 VNode 实例
* @param {import('vue').CreateElement} h Vue 的 createElement 函数
* @param {import('vue').Component|Function} Component 渲染的组件或渲染函数
* @param {string} key VNode 的 key
* @param {import('vue').Ref} ref 组件引用
* @returns {import('vue').VNode|null} 返回 VNode 实例或 null
*/
export function createVNode(h, Component, ref = null) {
if (!Component) return null;
/** @type { import('vue').VNode } */
let instance = null;
/** @name 升级h函数,统一混入ref **/
const render = (type, props = {}, children) => {
return h(
type,
{
...props,
ref: (el) => {
if (ref) ref.value = el;
},
},
children,
);
};
if (typeof Component === "function") {
instance = Component(render);
} else {
instance = render(Component);
}
return instance;
}
dialogWithComponent
这个函数是整个架构的核心!它的职责就像一个专业的 Dialog "召唤师":
脑袋突然蹦出一句话:"去吧,就决定是你了,皮卡丘(柱状图)"🎯
- 动态创建:不再需要在模板里预先写好
<el-dialog>
。dialogWithComponent
会在你需要的时候,通过createApp
和h
函数,动态地创建一个包含<el-dialog>
和你的内容组件的 Vue 应用实例。 - 挂载与销毁:它负责将创建的 Dialog 实例挂载到
document.body
上,并在 Dialog 关闭(确认、取消或点击遮罩层)后,优雅地将其从 DOM 中移除并销毁 Vue 实例,避免内存泄漏。 - Promise 驱动:调用
dialogWithComponent
会返回一个 Promise。当用户点击"确认"并成功获取数据后,Promise 会调用 resolve 并返回数据;如果用户点击"取消"或"关闭",Promise 会调用 reject 。这使得异步处理 Dialog 结果变得异常简洁,并且支持异步。 - 配置注入:你可以轻松地向
dialogWithComponent
传递<el-dialog>
的各种props
,实现 Dialog 的定制化。
createVNode
这个函数是 Vue 中 h
函数的升级版本,它主要是帮忙做内容的渲染🎩,它有两个小小的特点:
- 组件/函数通吃:你可以直接传递一个 Vue 组件 (
.vue
文件或 JS/TS 对象) 给它,它会用h
函数渲染这个组件。你还可以传递一个渲染函数!能让你在运行时动态决定渲染什么内容,简直不要太方便!是吧是吧。🤩 Ref
传递:它巧妙地集中处理了ref
,使得dialogWithComponent
函数可以获取到内容组件的实例 (contentRef.value
),从而能够调用内容组件暴露的方法(getValue
),非常关键的一点。⏰
基础的 Dialog 组件文件(components/BaseDialog.vue
):
<template>
<el-dialog v-bind="dialogAttrs">
<slot></slot>
<template v-if="showFooter" #footer>
<span>
<template v-if="!$slots.footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm">确定</el-button>
</template>
<slot v-else name="footer"></slot>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { useAttrs, computed } from "vue";
import { ElDialog, ElButton } from "element-plus";
defineProps({
showFooter: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
}
});
const emit = defineEmits(["dialogCancel", "dialogConfirm"]);
const attrs = useAttrs();
const dialogAttrs = computed(() => ({
...attrs,
}));
function handleCancel() {
emit("dialogCancel");
}
function handleConfirm() {
emit("dialogConfirm");
}
</script>
那么,整个核心代码的实现过程大概就是如此了。不知道你看完这部分的拆解,是否有了新的收获呢?😋
当然啦,在实际的业务场景中,代码的组织和细节处理会更加复杂,比如会涉及到更精细的状态管理、错误处理、权限控制、以及各种边界情况的兼容等等。这里为了突出咱们动态创建 Dialog 架构的核心思想,小编仅仅是把最关键的脉络拎了出来,并进行了一定程度的精简。
总结
总而言之,言而总之,这次架构的演进,给小编最大的感受就是🏗️从"各自为战"到"统一调度"。
告别了维护繁琐、数量庞大的单个 Dialog.vue 文件,转而拥抱了基于 createApp
和 h
函数的动态创建方式。
这种新模式下,基础 Dialog、配置面板 ( Panel.vue )、以及调用逻辑各司其职,实现了真正的高内聚、低耦合。最终使得整个项目结构更加清晰、代码更加健壮,也极大地提升了后续的可维护性。希望这套方案能给你带来一些启发!
最后,如果你有任何疑问或者更好的想法,随时欢迎交流哦!👇
至此,本篇文章就写完啦,撒花撒花。
来源:juejin.cn/post/7498737799204093978
用canvas实现一个头像上的间断式的能量条
今天遇到一个很有意思的面试题,面试官给我一道题目,要我实现之前它们公司之前写的一个组件。
首先我介绍下这道题,首先我是先想到用flex布局来写头像分布,因为grid布局不能实现头像最后一排不能居中的效果。
然后这道题的重点
来了,我一开始以为它头像上的边框是死的,是张贴图。然后我去问面试官,他说是一个能量条,能根据投票的数量进行改变。我脑袋有点懵,问ai也没结果,生成的非常垃圾,然后就开始思考怎么才能实现。首先想到的是echats,但没有找到合适的,我就开始想echats是用canvas写的,我就想用canvas写下,在bilibili上看了下canvas的使用方法,于是就想到了这道题的解法。这是我的成果。
我就不做过多的讲解关于canvas的使用方法,我只在我的演示代码注释中讲每条代码的作用,和使用方法。不会的话,可以去看看bilibili,然后做个笔记,然后就印象深刻了。
代码讲解
这里是初步实现的代码,写出了大概的轮廓方便理解。完整代码在最后面。
具体的代码讲解就写在注释中了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<canvas id="canvas" width="600" height="600" backgroud></canvas>
<script>
function animate() {
var canvas = document.getElementById('canvas');//找到canvas
var ctx = canvas.getContext('2d');//读取canvas的上下文,进行修改,就能实现对canvas的绘画
ctx.translate(canvas.width / 2, canvas.height / 2);//这个是将canvas的坐标轴移到中间
ctx.rotate(-Math.PI / 2);//这个是将坐标轴反向转动90度
ctx.strokeStyle = 'rgb(144, 211, 205)';//设置画笔的颜色
ctx.lineWidth = 20; // 这里是设置画笔的宽度,也就是能量条的宽度
ctx.lineCap = "butt"; //这里设置画笔结束的位置是圆的直的还是弯的
for (let i = 0; i < 17; i++) {//这里17表示要绘制17段线,到时候这里循环的次数会传过来在我后面的成品中。
ctx.beginPath();//这里开始绘制路径
// 绘制小段圆弧 (角度改为弧度制)
ctx.arc(0, 0, 100, -Math.PI / 34, Math.PI / 34, false);//前两个位置是圆心,第三个是半径,第四个是开始角度,第五个是结束角度,第六个是是否逆时针
ctx.stroke();//这个表填充绘画的轨迹
// 旋转到下一个位置
ctx.rotate(Math.PI / 16);//这里坐标轴顺时针移动一定角度,如果想要格子更多就设的更小,上面画线的角度也要调小
ctx.closePath()//结束绘制
}
}
animate();
</script>
</body>
</html>
成品代码
最后的成品我是用vue写的,没有特别去封装,毕竟只是面试题。
<template>
<div class="grid-container">
<div class="member-card" v-for="(member, index) in members" :key="index">
<canvas :id="'' + index" width="150" height="150"></canvas>
<div class="circle">
<img :src="member.avatar" alt="avatar" class="avatar" />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
const members = [
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 10 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 2 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 18 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 20 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 1 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 20 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 20 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 20 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 20 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 1 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 31 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 1 },
{ name: '用户A', avatar: 'https://img0.baidu.com/it/u=600722015,3838115472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=750', numbers: 1 },
];
onMounted(() => {
members.forEach((member, index) => {
drawEnergyBar(index, member.numbers); // 使用member.numbers作为参数
});
});
function drawEnergyBar(index, count) {
const canvas = document.getElementById(`canvas-${index}`);
const ctx = canvas.getContext('2d');
// 重置画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制设置
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.strokeStyle = 'rgb(144, 211, 205)';
ctx.lineWidth = 60;
ctx.lineCap = "butt";
// 根据传入的count值绘制线段
for (let i = 0; i < count; i++) {
ctx.beginPath();
ctx.arc(0, 0, 44, -Math.PI / 36, Math.PI / 36, false);
ctx.stroke();
ctx.rotate(Math.PI / 16);
}
}
</script>
<style scoped>
/* 修改canvas样式 */
canvas {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
/* 作为背景层 */
}
.member-card {
position: relative;
width: 150px;
height: 150px;
/* 添加固定高度 */
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.3s ease;
border: rgb(144, 211, 205) solid 2px;
border-radius: 50%;
background-color: black;
overflow: hidden;
}
.circle {
position: relative;
border: 2px solid black;
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
z-index: 2;
/* 确保在画布上方 */
margin: 0;
/* 移除外边距 */
}
.grid-container {
height: 100%;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 30px;
padding: 30px;
max-width: calc(150px * 6 + 30px * 5);
margin: 0 auto;
background: url(https://pic.nximg.cn/file/20230303/33857552_140701783106_2.jpg);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
}
.member-card {
position: relative;
width: 150px;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.3s ease;
border: rgb(144, 211, 205) solid 2px;
border-radius: 50%;
background-color: black;
}
.circle {
position: relative;
border: 2px solid black;
margin: 20px 20px;
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.avatar {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
</style>
结语
虽然这道题有点难,但好处是我对canvas的理解加深了,canvas绝对是前端的一个非常有用的东西,值得掘友们认真学习。原本这道题的灵感来源于bilibili上讲的canvas实现钟表中刻度的实现,虽然没用它的方法,因为他的方法会导致刻度变形,不是扇形的能量条,但是它旋转坐标轴的想法让我大受启发。
来源:juejin.cn/post/7501568955498070016
别再用 useEffect 写满组件了!试试这个三层数据架构 🤔🤔🤔
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777
。
我们常常低估了数据获取的复杂性,直到项目已经陷入困境。很多项目一开始只是简单地在组件中随手使用 useEffect()
和 fetch()
。
然而不知不觉中,错误处理、加载状态、缓存逻辑和重复请求越堆越多,代码变得混乱难以维护,调试也越来越痛苦。
以下是我在许多项目中常见的一些问题:
- 组件触发了重复的网络请求 —— 只是因为没有正确缓存数据
- 组件频繁重渲染 —— 状态管理混乱,每秒更新几十次
- 过多的骨架屏加载效果 —— 导致整个应用看起来总是“卡在加载中”
- 用户看到旧数据 —— 修改数据后缓存没有及时更新
- 并发请求出现竞态条件 —— 不同请求返回顺序无法预测,导致数据错乱
- 内存泄漏 —— 订阅和事件监听未正确清理
- 乐观更新失败却悄无声息 —— 页面展示和实际数据不一致
- 服务端渲染数据失效太快 —— 页面跳转后立即变成过期数据
- 无效的轮询逻辑 —— 要么频繁请求没有变化的数据,要么根本没必要轮询
- 组件与数据请求逻辑强耦合 —— 导致组件复用性极差
- 顺序依赖的多层请求链 —— 比如:获取用户 → 用户所属组织 → 组织下的团队 → 团队成员
以上问题在复杂应用中极为常见。如果不从架构层面进行规划和优化,很容易陷入混乱的技术债中,影响项目长期维护与扩展。
这些问题会互相叠加。一个糟糕的数据获取模式,往往会引发三个新的问题。等你意识到时,原本“简单”的仪表盘页面已经需要从头重构了。
这篇文章将向你展示一种更好的做法,至少是我在项目中偏好的架构方式。我们将构建一个三层数据获取架构,它可以从最基本的 CRUD 操作无缝扩展到复杂的实时应用,而不会让你陷入混乱的思维模型中。
不过在介绍这套“三层数据架构”之前,我们得先谈谈一个常见的起点:
你的第一反应可能是直接在组件里用 useEffect()
搭配 fetch()
来获取数据,然后继续开发下去。
但这种方式,很快就会失控。以下是原因:
export function TeamDashboard() {
const [user, setUser] = useState(null);
const [org, setOrg] = useState(null);
const [teams, setTeams] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
// Waterfall ❌
useEffect(() => {
const fetchData = async () => {
try {
// User request
const userData = await fetch("/api/user").then((res) => res.json());
setUser(userData);
// Wait for user, then fetch org
const orgData = await fetch(`/api/org/${userData.orgId}`).then((res) =>
res.json()
);
setOrg(orgData);
// Wait for org, then fetch teams
const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(
(res) => res.json()
);
setTeams(teamsData);
setIsLoading(false);
} catch (err) {
setError(err.message);
setIsLoading(false);
}
};
fetchData();
}, []);
// Handle window focus to refetch
useEffect(() => {
const handleFocus = async () => {
if (!user?.id) return;
setIsLoading(true);
await refetchData();
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [user?.id]);
// Polling for updates
useEffect(() => {
if (!user?.id || !org?.id) return;
const pollTeams = async () => {
try {
const teamsData = await fetch(`/api/teams?orgId=${org.id}`).then(
(res) => res.json()
);
setTeams(teamsData);
} catch (err) {
// Silent fail or show error?
console.error("Polling failed:", err);
}
};
const interval = setInterval(pollTeams, 30000);
return () => clearInterval(interval);
}, [user?.id, org?.id]);
const refetchData = async () => {
try {
const userData = await fetch("/api/user").then((res) => res.json());
const orgData = await fetch(`/api/org/${userData.orgId}`).then((res) =>
res.json()
);
const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(
(res) => res.json()
);
setUser(userData);
setOrg(orgData);
setTeams(teamsData);
setLastUpdated(new Date());
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
const createTeam = async (newTeam) => {
setIsCreating(true);
try {
const response = await fetch("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTeam),
});
if (!response.ok) throw new Error("Failed to create team");
const createdTeam = await response.json();
// Optimistic update attempt
setTeams((prev) => [...prev, createdTeam]);
// Or full refetch because you're paranoid
await refetchData();
} catch (err) {
setError(err.message);
// Need to rollback optimistic update?
// But which teams were the original ones?
} finally {
setIsCreating(false);
}
};
// Component unmount cleanup
useEffect(() => {
return () => {
// Cancel any pending requests?
// How do we track them all?
};
}, []);
// The render logic is still complex
if (isLoading && !teams.length) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorDisplay message={error} onRetry={refetchData} />;
}
return (
<div>
<h1>{org?.name}'s Dashboard</h1>
{isLoading && <div>Refreshing...</div>}
<TeamList teams={teams} onCreate={createTeam} isCreating={isCreating} />
{lastUpdated && (
<div>Last updated: {lastUpdated.toLocaleTimeString()}</div>
)}
</div>
);
}
这种“在组件中用 useEffect + fetch”的方式,存在大量问题:
- 瀑布式请求:请求按顺序依赖执行,效率低下(后面我们会详细讨论)
- 状态管理混乱:多个
useState
钩子相互独立,容易不同步 - 内存泄漏风险:事件监听器、定时器等需要手动清理,容易遗漏
- 无法取消请求:组件卸载时,无法终止正在进行中的请求
- 加载状态复杂:
isLoading
究竟是哪个请求在加载?多个并发请求怎么处理? - 错误处理难统一:错误冒泡到哪里?如何集中处理错误?
- 缓存数据过期问题:没有机制标记哪些数据已经过期
- 乐观更新灾难:需要手动写回滚逻辑,容易出错
- 依赖数组陷阱:一不小心漏了依赖,导致潜在 Bug 难以排查
- 测试极其困难:模拟这些副作用和状态逻辑是一场噩梦
当你的应用变得越来越复杂,这些问题会指数级地增长。每新增一个功能,就意味着更多的状态、更多的副作用、更多边界条件需要考虑。
当然,你也可以用 Redux 或 MobX 来集中管理状态,但这些库往往也会引入新的复杂度和大量样板代码。最终你会陷入一张难以理清的“action → reducer → selector”的关系网中。我自己也喜欢这两个库,但它们并不是解决这个问题的最佳答案。
你可能会想:“那我用 useReducer()
+ useContext()
管理状态不就好了?”
是的,这种组合确实可以整洁地组织状态,但它仍然没有解决数据获取本身的复杂性。加载状态、错误处理、缓存失效等问题依旧存在。
顺带一提,你可能还会想:“我干脆一次性把所有数据都请求回来,不就没这些问题了?”
接下来我们就来聊聊,为什么这也不可行。
export default function Dashboard() {
// Creates a waterfall - each request waits for the previous ❌
const { user } = useUser(); // Request 1
const { org } = useOrganization(user?.id); // Request 2 (waits)
const { teams } = useTeams(org?.id); // Request 3 (waits more)
// Total delay: 600-1200ms
return <DashboardView user={user} org={org} teams={teams} />;
}
Server Components(服务器组件)是一种更快、更高效的解决方案。它们允许你在服务器端获取数据,然后一次性将处理后的结果发送给客户端,从而:
- 减少前后端之间的网络请求次数
- 降低客户端的计算负担
- 提升页面加载速度和整体性能
通过在服务器上完成数据获取与渲染逻辑,Server Components 能帮助你构建更简洁、高性能的 React/Next.js 应用架构。
export default async function Dashboard() {
const user = await getUser();
// fetch org and teams in parallel using user data ✅
const [org, teams] = await Promise.all([
getOrganization(user.orgId),
getTeamsByOrgId(user.orgId),
]);
return <DashboardView user={user} org={org} teams={teams} />;
}
如果我告诉你,其实有一种更优雅的方式来组织数据获取逻辑,不仅能随着应用的增长而扩展,还能让你的组件保持简洁、专注 —— 你会不会感兴趣?
这正是 “三层数据架构(Three Layers of Data Architecture)” 的核心思想。这个模式将数据获取逻辑划分为三个清晰的层级,每一层都各司其职,互不干扰。
这样的设计让你的应用:
- 更容易理解
- 更方便测试
- 更便于维护和扩展
接下来我们就来深入了解这三层到底是什么。
三层数据架构
解决方案就是构建一个三层架构,实现关注点分离,让你的应用更容易理解、维护和扩展。
这种架构理念受到 React Query 的启发,它为管理服务端状态提供了一套强大且高效的解决方案。
你不一定非得使用 React Query,但我个人非常推荐它作为数据获取与缓存的首选库。
它帮你处理掉大量样板代码,让你可以专注于业务逻辑和界面开发。
💡 小提示:如果你选择使用 React Query,别忘了在开发环境中加上
<ReactQueryDevtools />
—— 这个调试工具会极大提升你的开发体验。
回到“三层架构”本身。其实它的结构非常简单:
- 服务器组件(Server Components) —— 负责初始数据获取
- React Query —— 处理客户端的缓存与数据更新
- 乐观更新(Optimistic Updates) —— 提供即时的 UI 反馈
React Query 支持两种方式来实现乐观更新(即在真正完成数据变更之前就提前更新界面):
- 使用
onMutate
钩子,直接操作缓存实现数据预更新 - 或者通过
useMutation
的返回值,根据变量手动更新 UI
这种模式不仅让用户感受到更快的响应,还能保持数据与界面的同步性。
下面是一个推荐的项目结构示例,用来更清晰地理解这三层架构的组织方式:
app/
├── page.tsx # Layer 1: Server Component entry
├── api/
│ └── teams/
│ └── route.ts # GET, POST teams
│ └── [teamId]/
│ └── route.ts # GET, PUT, DELETE specific team
├── TeamList.tsx # Client component consuming Layers 2 & 3
├── components/ # Fix: Add this folder
│ └── TeamCard.tsx
└── ui/
├── error-state.tsx # Layer 2: Error handling states
└── loading-state.tsx # Layer 2: Loading states
hooks/
├── teams/
│ ├── useTeamsData.ts # Layer 2: React Query hooks
│ └── useTeamMutations.ts # Layer 3: Mutations with optimism
queries/ # Layer 1: Server-side database queries
├── teams/
│ ├── getAllTeams .ts
│ ├── getTeamById.ts
│ ├── getTeamsByOrgId.ts
│ ├── deleteTeamById.ts
│ ├── createTeam.ts
│ ├── updateTeamById.ts
context/
└── OrganizationContext.tsx # Layer 2: Centralized data management
三层架构的数据如何流动?
这三个层按顺序工作但保持独立:
用户请求(User Request)
↓
【第一层:服务器组件(Server Component)】
- 调用 getAllTeams() 从数据库获取数据
- 返回已渲染的 HTML(含初始数据)
↓
【第二层:React Query(客户端状态管理)】
- 接收并“脱水”服务器返回的数据(hydrate)
- 管理客户端缓存
- 处理自动/手动重新请求(refetch)
↓
【第三层:用户交互(User Actions)】
- 执行乐观更新,立即反馈 UI
- 发起真实的变更请求(mutation)
- 自动或手动触发缓存失效(cache invalidation)
第一层:Server Components
服务器组件负责处理初始数据获取,让你的应用感觉即时可用。但它们不会动态更新——这时 React Query 就派上用场了(第二层)。
import { getAllTeams } from "@/queries/teams/getAllTeams";
import { TeamList } from "./TeamList";
import { OrganizationProvider } from "@/context/OrganizationContext";
export default async function Page() {
// Layer 1: Fetch initial data on server
const teams = await getAllTeams();
return (
<main>
<h1>Teams Dashboard</h1>
{/* Pass server data to React Query via context */}
<OrganizationProvider initialTeams={teams}>
<TeamList />
</OrganizationProvider>
</main>
);
}
getAllTeams
函数是一个简单的数据库查询,用于获取所有团队。它可以是一个简单的 SQL 查询,也可以是一个 ORM 调用,具体取决于您的设置。
如下代码所示:
import { db } from "@/lib/db"; // Database or ORM connection
import { Team } from "@/types/team";
import { NextResponse } from "next/server";
export async function getAllTeams(): Promise<Team[]> {
try {
const teams = await db.team.findMany();
return teams;
} catch (error) {
throw new Error("Failed to fetch teams");
}
}
第二层:React Query
第 2 层使用来自第 1 层的初始数据并管理客户端状态:
import { useQuery } from "@tanstack/react-query";
export function useTeamsData(initialData: Team[]) {
return useQuery({
queryKey: ["teams"],
queryFn: async () => {
// Client-side must use API routes, not direct queries
// I want to keep my server and client code separate
const response = await fetch("/api/teams");
if (!response.ok) throw new Error("Failed to fetch teams");
return response.json();
},
initialData, // Received from Server Component via context
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
以下是客户端组件从第 2 层消费的方式。
"use client";
import { useOrganization } from "@/context/OrganizationContext";
import { LoadingState } from "@/ui/loading-state";
import { ErrorState } from "@/ui/error-state";
export function TeamList() {
// Data from Layer 2 context
const { teams, isLoadingTeams, error } = useOrganization();
if (error) {
return <ErrorState message="Failed to load teams" />;
}
if (isLoadingTeams) {
return <LoadingState />;
}
return (
<div>
{teams.map((team) => (
<TeamCard key={team.id} team={team} />
))}
</div>
);
}
真正的“魔法”发生在第三层,这一层让你在服务器还在处理请求时,就能立即更新 UI,带来极致流畅的用户体验 —— 这正是乐观更新(Optimistic Updates)的价值所在。
在这个层中,所有变更请求(mutations)都被集中管理,例如创建或删除团队。
我们通常会把这部分逻辑封装在一个独立的 Hook 中,比如 useTeamMutations
,它内部使用 React Query 的 useMutation
来处理对应的操作,从而让业务逻辑更清晰、职责更明确、代码更易维护。
// Layer 3: Mutations with optimism
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useTeamMutations() {
const queryClient = useQueryClient();
const createTeamMutation = useMutation({
mutationFn: async (newTeam: { name: string; members: string[] }) => {
const response = await fetch("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTeam),
});
if (!response.ok) throw new Error("Failed to create team");
return response.json();
},
onMutate: async (newTeam) => {
await queryClient.cancelQueries({ queryKey: ["teams"] });
const currentTeams = queryClient.getQueryData(["teams"]);
queryClient.setQueryData(["teams"], (old) => [
...old,
{ ...newTeam, id: `temp-${Date.now()}` },
]);
return { currentTeams };
},
onError: (err, variables, context) => {
queryClient.setQueryData(["teams"], context.currentTeams);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["teams"] });
},
});
const deleteTeamMutation = useMutation({
mutationFn: async (teamId: string) => {
const response = await fetch(`/api/teams/${teamId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete team");
return response.json();
},
onMutate: async (teamId) => {
await queryClient.cancelQueries({ queryKey: ["teams"] });
const currentTeams = queryClient.getQueryData(["teams"]);
queryClient.setQueryData(["teams"], (old) =>
old.filter((team) => team.id !== teamId)
);
return { currentTeams };
},
onError: (err, teamId, context) => {
queryClient.setQueryData(["teams"], context.currentTeams);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["teams"] });
},
});
return {
createTeam: createTeamMutation.mutate,
deleteTeam: deleteTeamMutation.mutate,
isCreating: createTeamMutation.isLoading,
isDeleting: deleteTeamMutation.isLoading,
};
}
TeamCard 组件使用 useTeamMutations 钩子来处理团队的创建和删除。它还显示每个操作的加载状态。
"use client";
// TeamList.tsx - Using Layer 3 mutations
import { useTeamMutations } from "@/hooks/teams/useTeamMutations";
interface TeamCardProps {
team: {
id: string;
name: string;
members: string[];
};
}
export function TeamCard({ team }: TeamCardProps) {
const { deleteTeam, isDeleting } = useTeamMutations();
return (
<div className="p-4 border border-gray-200 rounded-lg mb-4">
<h3 className="text-lg font-semibold">{team.name}</h3>
<p className="text-gray-600">Members: {team.members.length}</p>
<button
onClick={() => deleteTeam(team.id)}
disabled={isDeleting}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? "Deleting..." : "Delete Team"}
</button>
</div>
);
}
将所有内容联系在一起:Context
上下文提供程序消除了 prop 钻取,并集中了数据访问。这对于多个组件需要相同数据的复杂应用尤其有用。
import { createContext, useContext } from "react";
import { useTeamsData } from "@/hooks/teams/useTeamsData";
interface OrganizationContextValue {
teams: Team[];
isLoadingTeams: boolean;
error: Error | null;
}
const OrganizationContext = createContext<OrganizationContextValue | null>(
null
);
export function OrganizationProvider({ children, initialTeams }) {
const { data: teams, isLoading, error } = useTeamsData(initialTeams);
return (
<OrganizationContext.Provider
value={{ teams, isLoadingTeams: isLoading, error }}
>
{children}
</OrganizationContext.Provider>
);
}
export function useOrganization() {
const context = useContext(OrganizationContext);
if (!context) {
throw new Error("useOrganization must be used within OrganizationProvider");
}
return context;
}
OrganizationProvider
组件包裹了 TeamList
,为其提供了第一层的初始数据,同时统一管理加载状态与错误处理。
在更复杂的应用中,你可以为不同的数据层增加更多的上下文(Context)提供器。
比如,你可能会有:
- 一个
UserContext
来管理用户信息 - 一个
AuthContext
来处理认证状态
通过这种方式,你的组件可以专注于渲染 UI,而数据获取与状态管理则被集中管理,逻辑更清晰、职责更分明。
需要注意的是:
对于简单应用而言,“三层数据架构”可能有点大材小用。
但对于中大型项目来说,它具有极高的可扩展性,能很好地应对不断增长的复杂度。
此外,它还让测试变得更加简单 —— 你可以通过模拟(mock)这些 Context Provider,来独立测试每个组件,无需依赖真实数据。
P.S.:这个架构不仅限于 React。你在 Vue.js、Svelte 或其他前端框架中也可以采用类似的思路。关键在于:关注点分离,让组件专注于“渲染”,而不是“获取数据”或“管理状态”。
总结
这篇文章介绍了一个适用于复杂 React/Next.js 应用的 三层数据架构,通过将数据获取流程拆分为 Server Components、React Query 和用户交互三层,解决了传统 useEffect + fetch 带来的各种性能与维护问题。该模式强调 关注点分离,提升了组件的复用性、可测试性和扩展能力。尽管对小项目可能偏重,但在中大型应用中具备良好的可扩展性和清晰的逻辑组织能力,是构建健壮前端架构的实用指南。
来源:juejin.cn/post/7503449107542016040
初级、中级、高级前端工程师,对于form表单实现的区别
在 React 项目中使用 Ant Design(Antd)的 Form 组件能快速构建标准化表单,特别适合中后台系统开发。以下是结合 Antd 的 最佳实践 和 分层实现方案:
一、基础用法:快速搭建标准表单
import { Form, Input, Button, Checkbox } from 'antd';
const BasicAntdForm = () => {
const [form] = Form.useForm();
const onFinish = (values: any) => {
console.log('提交数据:', values);
};
return (
<Form
form={form}
layout="vertical"
initialValues={{ remember: true }}
onFinish={onFinish}
>
{/* 邮箱字段 */}
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' }
]}
>
<Input placeholder="user@example.com" />
</Form.Item>
{/* 密码字段 */}
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 8, message: '至少8位字符' }
]}
>
<Input.Password />
</Form.Item>
{/* 记住我 */}
<Form.Item name="remember" valuePropName="checked">
<Checkbox>记住登录状态</Checkbox>
</Form.Item>
{/* 提交按钮 */}
<Form.Item>
<Button type="primary" htmlType="submit">
登录
</Button>
</Form.Item>
</Form>
);
};
核心优势:
- 内置校验系统:通过
rules
属性快速定义验证规则 - 布局控制:
layout="vertical"
自动处理标签对齐 - 状态管理:
Form.useForm()
自动处理表单状态
二、中级进阶:复杂场景处理
1. 动态表单字段(如添加多个联系人)
import { Form, Button } from 'antd';
const DynamicForm = () => {
return (
<Form>
<Form.List name="contacts">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<div key={key} style={{ display: 'flex' }}>
<Form.Item
{...rest}
name={[name, 'phone']}
rules={[{ required: true }]}
>
<Input placeholder="手机号" />
</Form.Item>
<Button onClick={() => remove(name)}>删除</Button>
</div>
))}
<Button onClick={() => add()}>添加联系人</Button>
</>
)}
</Form.List>
</Form>
);
};
2. 异步验证(如检查用户名是否重复)
<Form.Item
name="username"
rules={[
{ required: true },
{
validator: (_, value) =>
fetch(`/api/check?username=${value}`)
.then(res => res.ok ? Promise.resolve() : Promise.reject('用户名已存在'))
}
]}
>
<Input />
</Form.Item>
3. 条件渲染字段(如选择国家后显示省份)
const { watch } = useForm();
const country = watch('country');
<Form.Item name="province" hidden={!country}>
<Select options={provinceOptions} />
</Form.Item>
三、高级优化:性能与可维护性
1. 表单性能优化
// 使用 shouldUpdate 避免无效渲染
<Form.Item shouldUpdate={(prev, current) => prev.country !== current.country}>
{({ getFieldValue }) => (
getFieldValue('country') === 'CN' && <ProvinceSelect />
)}
</Form.Item>
2. 类型安全(TypeScript)
interface FormValues {
email: string;
password: string;
}
const [form] = Form.useForm<FormValues>();
3. 主题定制(通过 ConfigProvider)
import { ConfigProvider } from 'antd';
<ConfigProvider
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 4,
},
components: {
Form: {
labelColor: '#333',
},
},
}}
>
<YourFormComponent />
</ConfigProvider>
四、企业级解决方案
1. 表单设计器集成
// 结合 XFlow 实现可视化表单设计
import { XFlow, FormBuilder } from '@antv/xflow';
const FormDesigner = () => (
<XFlow>
<FormBuilder
components={registeredComponents} // 注册的Antd组件
onSave={(schema) => saveToBackend(schema)}
/>
</XFlow>
);
2. 微前端表单共享
// 使用 qiankun 共享表单组件
export default function AntdFormModule() {
return (
<Module name="form-module">
<ConfigProvider>
<Router>
<Route path="/form" component={YourAntdForm} />
</Router>
</ConfigProvider>
</Module>
);
}
五、Ant Design Form 的局限与应对策略
场景 | 问题 | 解决方案 |
---|---|---|
大数据量表单 | 渲染性能下降 | 虚拟滚动(react-virtualized) |
复杂联动逻辑 | 代码复杂度高 | 使用 Form.Provider 共享状态 |
深度定制UI | 样式覆盖困难 | 使用 CSS-in-JS 覆盖样式 |
多步骤表单 | 状态保持困难 | 结合 Zustand 做全局状态管理 |
跨平台需求 | 移动端适配不足 | 配合 antd-mobile 使用 |
六、推荐技术栈组合
- **基础架构**:React 18 + TypeScript 5
- **UI 组件库**:Ant Design 5.x
- **状态管理**:Zustand(轻量)/ Redux Toolkit(复杂场景)
- **表单增强**:@ant-design/pro-form(ProComponents)
- **验证库**:yup/zod + @hookform/resolvers(可选)
- **测试工具**:Jest + Testing Library
通过 Ant Design Form 组件,开发者可以快速构建符合企业标准的中后台表单系统。关键在于:
- 合理使用内置功能(Form.List、shouldUpdate)
- 类型系统深度整合
- 性能优化意识
- 扩展能力设计(动态表单、可视化配置)
来源:juejin.cn/post/7498950758475055119
TensorFlow.js 和 Brain.js 全面对比:哪款 JavaScript AI 库更适合你?
温馨提示
由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端
AI
开发之旅提供实用参考。 😊
引言:前端 AI
的崛起
在过去的十年里,人工智能(AI
)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶,从图像识别到自然语言处理,AI 的应用场景几乎无处不在。而对于前端开发者来说,AI
的魅力不仅在于其强大的功能,更在于它已经走进了浏览器,让客户端也能够轻松承担起机器学习的任务。
试想一下,当你开发一个 Web
应用,需要进行图像识别、文本分析、语音识别或其他 AI
任务时,你是否希望直接在浏览器中处理这些数据,而无需依赖远程服务器?如果能在用户的设备上本地运行这些任务,不仅可以大幅提升响应速度,还能减少服务器资源的消耗,为用户提供更流畅的体验。
这正是 TensorFlow.js
和 Brain.js
两款库所带来的变革。它们使开发者能够在浏览器中轻松实现机器学习任务,甚至支持训练和推理深度学习模型。虽然这两款库在某些功能上有相似之处,但它们的定位和特点却各有侧重。
TensorFlow.js
是由 Google
推出的深度学习框架,它为浏览器端的机器学习提供了强大的支持,能够处理从图像识别到自然语言处理的复杂任务。基于 WebGL
提供加速,TensorFlow.js
可以充分利用硬件性能,实现大规模数据处理和复杂模型推理。
TensorFlow.js
不仅功能强大,还能直接在浏览器中运行复杂的机器学习任务,例如图像识别和处理。如果你想深入了解如何使用 TensorFlow.js
构建智能图像处理应用,可以参考我的另一篇文章:纯前端用 TensorFlow.js
实现智能图像处理应用(一)。
相比之下,Brain.js
是一款轻量级神经网络库,专注于简单易用的神经网络模型。它的设计目标是降低机器学习的入门门槛,适合快速原型开发和小型应用场景。尽管 Brain.js
不具备 TensorFlow.js
那样强大的深度学习能力,但它的简洁性和易用性使其成为许多开发者快速实验和实现基础 AI 功能的优选工具。
然而,选择哪款库作为前端 AI
的工具并不简单,这取决于项目的需求、性能要求以及学习成本等多个因素。本文将详细对比两款库的功能、优缺点及适用场景,帮助你根据需求选择最适合的工具。
无论你是 AI
初学者还是有经验的开发者,相信你都能从这篇文章中找到有价值的指导,助力你在浏览器端实现机器学习。准备好了吗?让我们一起探索 TensorFlow.js
和 Brain.js
的世界,发现它们的不同之处,了解哪一个更适合你的项目。
一、TensorFlow.js
- 强大而复杂的深度学习库
1.1 TensorFlow.js
概述
TensorFlow.js
是由 Google
推出的开源 JavaScript
库,用于在浏览器和 Node.js
环境中执行机器学习任务,包括深度学习模型的推理和训练。它是 TensorFlow
生态的一部分,TensorFlow
是全球最受欢迎的深度学习框架之一,广泛应用于计算机视觉、自然语言处理等领域。
TensorFlow.js
的核心亮点在于其 跨平台支持。你可以在浏览器端运行,也可以在 Node.js
环境下执行,灵活满足不同开发需求。此外,它支持导入已训练好的 TensorFlow
或 Keras
模型,在浏览器或 Node.js
中进行推理,无需重新训练。这使得 AI 的开发更加高效和便捷。
1.2 TensorFlow.js
的功能特点
TensorFlow.js
提供了丰富的功能,覆盖从简单的机器学习到复杂的深度学习任务。以下是它的核心特点:
- 浏览器端深度学习推理:通过
WebGL
加速,TensorFlow.js
可以高效地在浏览器中加载和运行深度学习模型,无需依赖服务器,大幅提升用户体验和响应速度。 - 训练与推理一体化:
TensorFlow.js
支持在前端环境直接训练神经网络,这对于动态数据更新和快速迭代非常有用。即使是复杂的深度学习模型,也能通过优化技术确保高效的训练过程。 - 支持复杂神经网络架构:包括卷积神经网络(
CNN
)、循环神经网络(RNN
)、以及高级模型如Transformer
,适用于图像、语音、文本等多领域任务。 - 模型导入与转换:支持从其他
TensorFlow
或Keras
环境导入已训练的模型,并在浏览器或Node.js
中高效运行,降低了开发门槛。 - 跨平台支持:无论是前端浏览器还是后端
Node.js
,TensorFlow.js
都可以灵活适配,特别适合需要多环境协作的项目。
1.3 TensorFlow.js
的优势与应用场景
优势:
- 本地化计算:无需数据传输到服务器,所有计算均在用户设备上完成,提升速度并保障隐私。
- 强大的生态支持:依托
TensorFlow
的生态系统,TensorFlow.js
可以轻松访问预训练模型、教程和工具。 - 灵活性与高性能:支持低级别
API
和WebGL
加速,可根据需求灵活调整模型和计算流程。 - 无需后台服务器:在浏览器中即可完成复杂的训练和推理任务,显著简化系统架构。
应用场景:
- 图像识别:例如手写数字识别、人脸检测、物体分类等实时图像处理任务。
- 自然语言处理:支持情感分析、文本分类、语言翻译等复杂 NLP 任务。
- 实时数据分析:适用于
IoT
或其他需要即时数据处理和反馈的应用场景。 - 推荐系统:通过用户行为数据构建个性化推荐,例如电商、新闻或社交媒体应用。
1.4 TensorFlow.js
基本用法示例
以下是一个简单示例,展示如何使用 TensorFlow.js
构建并训练神经网络模型。
安装与引入 TensorFlow.js
- 通过
CDN
引入:
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
- 通过
npm
安装(适用于Node.js
环境):
npm install @tensorflow/tfjs
创建简单神经网络
以下示例创建了一个简单的前馈神经网络,用于处理二分类问题:
// 导入 TensorFlow.js
const tf = require('@tensorflow/tfjs');
// 创建一个神经网络模型
const model = tf.sequential();
// 添加隐藏层(10 个神经元)
model.add(tf.layers.dense({ units: 10, activation: 'relu', inputShape: [5] }));
// 添加输出层(2 类分类问题)
model.add(tf.layers.dense({ units: 2, activation: 'softmax' }));
// 编译模型
model.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
训练和推理过程
训练模型需要提供输入数据(特征)和标签(目标值):
// 创建训练数据
const trainData = tf.tensor2d([[0, 1, 2, 3, 4], [1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]);
const trainLabels = tf.tensor2d([[1, 0], [0, 1], [1, 0]]);
// 训练模型
model.fit(trainData, trainLabels, { epochs: 10 }).then(() => {
// 使用新数据进行推理
const input = tf.tensor2d([[1, 2, 3, 4, 5]]);
model.predict(input).print();
});
二、Brain.js
- 轻量级且易于使用的神经网络库
2.1 Brain.js
概述
Brain.js
是一个轻量级的开源 JavaScript
神经网络库,专为开发者提供快速、简单的机器学习工具。它的设计理念是易用性和轻量化,适合那些希望快速构建和训练神经网络的开发者,尤其是机器学习的新手。
与功能丰富的 TensorFlow.js
不同,Brain.js
更注重于直观和简单,能够帮助开发者快速完成从构建到推理的基本机器学习任务。虽然它不支持复杂的深度学习模型,但其易用性和小巧的特性,使其成为小型项目和快速原型开发的理想选择。
2.2 Brain.js
的功能特点
Brain.js
的功能主要集中在简化神经网络的构建与训练上,以下是其核心特点:
- 简单易用的
API
:Brain.js
提供了直观的接口,开发者无需复杂的机器学习知识,也能轻松上手并实现神经网络任务。 - 轻量级:相较于体积较大的
TensorFlow.js
,Brain.js
的核心库更为小巧,非常适合嵌入前端应用,且不会显著影响加载速度。 - 支持多种网络结构:前馈神经网络(
Feedforward Neural Network
)、LSTM
网络(Long Short-Term Memory
)等。这些模型已足够应对大多数基础的机器学习需求。 - 快速训练与推理:通过几行代码即可完成训练与推理任务,适用于快速原型设计和验证。
- 同步与异步训练支持:
Brain.js
同时支持同步和异步的训练过程,开发者可以根据项目需求选择合适的方式。
2.3 Brain.js
的优势与应用场景
优势:
- 快速原型开发:开发者可以用最少的代码完成神经网络的构建和训练,特别适合需要快速验证想法的场景。
- 轻量级与高效率:库的体积较小,能快速加载,适合资源有限的环境。
- 易于集成:
Brain.js
非常适合嵌入Web
应用或小型Node.js
服务,集成简单。 - 适合初学者:
Brain.js
的设计对机器学习新手友好,无需深入了解复杂的深度学习算法即可上手。
应用场景:
- 基础分类与预测任务:适合实现简单的分类任务或数值预测,例如时间序列预测、情感分析等。
- 教学与实验:对于机器学习教学或学习过程中的快速实验,
Brain.js
是一个很好的工具。 - 轻量化应用:例如小型交互式
Web
应用中实时处理用户输入。
2.4 Brain.js
基本用法示例
以下示例展示了如何使用 Brain.js
构建并训练一个简单的神经网络模型。
安装与引入
- 通过 npm 安装:
npm install brain.js
- 通过 CDN 引入:
<script src="https://cdn.jsdelivr.net/npm/brain.js"></script>
创建简单神经网络
以下代码创建了一个用于解决 XOR
问题的前馈神经网络:
// 引入 Brain.js
const brain = require('brain.js');
// 创建一个简单的神经网络实例
const net = new brain.NeuralNetwork();
// 提供训练数据
const trainingData = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] }
];
// 训练网络
net.train(trainingData);
// 测试推理
const output = net.run([1, 0]);
console.log(`预测结果: ${output}`); // 输出接近 1 的值
训练与推理参数调整
Brain.js
提供了一些可选参数,用于优化训练过程,例如:
- 迭代次数(
iterations
) :设置训练的最大轮数。 - 学习率(
learningRate
) :控制每次更新的步长。
以下示例展示了如何自定义训练参数:
net.train(trainingData, {
iterations: 1000, // 最大训练轮数
learningRate: 0.01, // 学习率
log: true, // 显示训练过程
logPeriod: 100 // 每 100 次迭代打印一次日志
});
// 推理新数据
const testInput = [0, 1];
const testOutput = net.run(testInput);
console.log(`输入: ${testInput}, 预测结果: ${testOutput}`);
三、TensorFlow.js
和 Brain.js
的全面对比
在这一章中,我们将从多个维度对 TensorFlow.js
和 Brain.js
进行详细对比,帮助开发者根据自己的需求选择合适的工具。对比内容涵盖技术实现差异、学习曲线、适用场景、性能表现以及生态系统和社区支持。
3.1 技术实现差异
TensorFlow.js
和 Brain.js
的技术实现差异显著,主要体现在功能复杂度、支持的模型类型和底层架构上:
TensorFlow.js
是一个功能全面的深度学习框架,基于TensorFlow
的设计思想,提供了复杂的神经网络架构和高效的数学计算支持。它支持卷积神经网络(CNN
)、循环神经网络(RNN
)、生成对抗网络(GAN
)等多种模型类型,能够完成从图像识别到自然语言处理的复杂任务。借助WebGL
技术,TensorFlow.js
可在浏览器中高效进行高性能计算,尤其适合大规模数据和复杂模型。Brain.js
则更加轻量,主要面向快速开发和简单任务。它支持前馈神经网络(Feedforward Neural Network
)、长短期记忆网络(LSTM
)等基础模型,适合处理简单的分类或预测问题。尽管功能不如TensorFlow.js
广泛,但其简洁的设计使开发者能够快速上手,完成实验和小型项目。
总结:TensorFlow.js
更加强大,适用于复杂任务;Brain.js
简单轻便,适合快速开发和小型应用。
3.2 学习曲线与开发者体验
在学习曲线和开发体验方面,两者差异明显:
TensorFlow.js
学习曲线较为陡峭。其功能强大且覆盖面广,但开发者需要了解深度学习的基础知识,包括模型训练、数据预处理等环节。尽管文档和教程丰富,但对初学者而言,掌握这些内容可能需要投入更多的时间和精力。Brain.js
则以简洁直观的 API 著称,初学者可以通过几行代码实现神经网络的搭建与训练。它对复杂概念的抽象程度高,无需深入理解深度学习理论,便能快速完成任务。
总结:如果你是新手或需要快速实现一个简单模型,选择 Brain.js
更友好;而如果你已有一定经验,并计划处理复杂任务,则 TensorFlow.js
更适合。
3.3 适用场景与功能选择
根据应用场景,选择合适的库可以大大提高开发效率:
TensorFlow.js
:适用于复杂任务,如图像识别、自然语言处理、视频分析或推荐系统。由于其强大的深度学习功能和高性能计算能力,TensorFlow.js
特别适合大规模数据处理和精度要求高的场景。Brain.js
:适合轻量级任务,例如简单的分类、回归、时间序列预测等。对于快速验证模型或开发原型,Brain.js
提供了简单高效的解决方案,尤其是在浏览器端运行时无需依赖复杂的服务器计算。
总结:TensorFlow.js
面向复杂场景和大规模任务;Brain.js
更适合轻量化需求和快速开发。
3.4 性能对比
在性能方面,TensorFlow.js
和 Brain.js
存在显著差异:
TensorFlow.js
借助WebGL
实现高效的硬件加速,支持 GPU 并行计算。在处理大规模数据集和复杂模型时,其性能优势显著,适用于高负载、高计算量的场景。Brain.js
性能较为有限,主要针对小型数据集和简单任务。由于其轻量级设计,虽然在小规模任务中表现出色,但无法与TensorFlow.js
的硬件加速能力相媲美。
总结:对于需要高性能计算的场景,TensorFlow.js
是更优选择;而对于小型任务,Brain.js
的性能已足够。
3.5 生态系统与社区支持
TensorFlow.js
:作为TensorFlow
生态的一部分,TensorFlow.js
享有丰富的社区资源和支持,包括大量的开源项目、教程、论坛和工具。开发者可以从官方文档和预训练模型中快速找到所需资源,支持复杂应用的开发。Brain.js
:社区较小,但活跃度高。文档简洁,适合初学者。虽然资源和支持不如TensorFlow.js
丰富,但足以满足小型项目的需求。
总结:TensorFlow.js
的生态更强大,适合需要长期维护和扩展的项目;Brain.js
更适合轻量化开发和快速上手。
四、如何选择最适合你的库?
在 TensorFlow.js
和 Brain.js
之间做出选择时,开发者需要综合考虑项目需求、技术背景和性能要求。这两款库各有特色:TensorFlow.js
功能强大,适用于复杂任务;Brain.js
简单易用,适合快速开发。以下从选择标准和实际场景出发,帮助开发者找到最合适的工具。
4.1 选择标准
在选择 TensorFlow.js
或 Brain.js
时,可参考以下几个关键标准:
- 功能需求:
- 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择
TensorFlow.js
更为合适。它支持复杂的神经网络模型,具备高效的数据处理能力。 - 基础任务:如果需求相对简单,例如小型神经网络模型、时间序列预测或分类任务,
Brain.js
是更轻量的选择。
- 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择
- 开发者经验:
- 有机器学习背景:
TensorFlow.js
提供高度灵活的 API,但学习曲线较陡。熟悉机器学习的开发者可以充分利用其强大功能。 - 初学者:
Brain.js
更适合新手,提供简洁的接口和直观的使用体验。
- 有机器学习背景:
- 性能需求:
- 高性能计算:如果项目需要硬件加速(如
GPU
支持)以处理大规模数据,TensorFlow.js
的WebGL
支持是理想选择。 - 轻量化应用:对于性能要求较低的场景,
Brain.js
的轻量级设计足够满足需求。
- 高性能计算:如果项目需要硬件加速(如
- 项目规模与复杂度:
- 大型项目:
TensorFlow.js
提供复杂功能和强大的扩展性,适合长期维护和生产级应用。 - 快速开发:
Brain.js
专注于快速实现小型项目,适合验证想法或开发MVP
(最小可行产品)。
- 大型项目:
4.2 基于项目需求的选择建议
以下是根据常见场景的具体选择建议:
场景一:图像分类应用
- 需求:对大规模图像进行分类或识别,涉及复杂的卷积神经网络(
CNN
)。 - 推荐选择:
TensorFlow.js
。支持复杂模型架构,通过WebGL
提供高效的硬件加速,适合处理大量图像数据。
场景二:实时数据分析与预测
- 需求:对传感器数据进行实时监测和分析,预测未来趋势(如气象预测、股票走势)。
- 推荐选择:
Brain.js
。其轻量化和快速实现的特性非常适合实时数据处理和快速部署。
场景三:自然语言处理(NLP
)应用
- 需求:需要对文本数据进行分类、情感分析或对话生成。
- 推荐选择:
TensorFlow.js
。支持循环神经网络(RNN
)、Transformer
等复杂模型,能处理 NLP 任务的高维数据和复杂结构。
场景四:个性化推荐系统
- 需求:根据用户行为推荐商品或内容。
- 推荐选择:
- 如果推荐系统复杂,涉及神经协同过滤或深度学习模型,选择
TensorFlow.js
。 - 如果系统较为简单,仅需基于用户行为的规则实现,
Brain.js
是更高效的选择。
- 如果推荐系统复杂,涉及神经协同过滤或深度学习模型,选择
场景五:快速原型开发与实验
- 需求:验证机器学习模型效果或快速开发实验性产品。
- 推荐选择:
Brain.js
。它提供简洁的接口和快速训练功能,适合快速搭建和迭代。
结论:最终选择
通过对 TensorFlow.js
和 Brain.js
的详细对比,可以帮助开发者根据项目需求和个人技能做出最佳选择。以下是两者的优缺点总结及适用场景的建议。
TensorFlow.js
优缺点
优点:
- 功能全面:支持复杂的深度学习模型(如
CNN
、RNN
、GAN
),适用于广泛的机器学习任务,包括图像识别、自然语言处理和语音处理等。 - 跨平台支持:可运行于浏览器和
Node.js
环境,灵活部署于多种平台。 - 性能卓越:利用
WebGL
实现硬件加速,适合高性能需求,尤其是大规模数据处理。 - 强大的生态系统:依托
TensorFlow
生态,拥有丰富的预训练模型、教程和社区支持,为开发者提供充足资源。
缺点:
- 学习门槛较高:功能复杂,适合有机器学习基础的开发者,初学者可能需要投入较多时间学习。
- 库体积较大:功能的多样性导致库体积偏大,可能影响浏览器加载速度和资源消耗。
Brain.js
优缺点
优点:
- 轻量级与易用性:设计简单,API 直观,非常适合快速开发和机器学习初学者。
- 小巧体积:库文件体积小,适合嵌入前端应用,对网页加载影响小。
- 支持基础模型:支持前馈神经网络和
LSTM
,能满足大多数基础机器学习任务。 - 快速上手:开发者无需深厚的机器学习知识,能够快速实现简单神经网络应用。
缺点:
- 功能较为局限:不支持复杂深度学习模型,难以满足高阶任务需求。
- 性能有限:轻量设计决定其在大规模数据处理中的性能不如
TensorFlow.js
。
适用场景与开发者建议
初学者或简单任务:
- 选择:
Brain.js
- 理由:适合刚接触机器学习的开发者,或处理简单分类、时间序列预测等基础任务。其平缓的学习曲线和快速开发特性,帮助初学者快速上手。
经验丰富的开发者或复杂任务:
- 选择:
TensorFlow.js
- 理由:适合处理复杂的深度学习任务,如大规模图像识别、自然语言处理或实时视频分析。提供灵活的 API 和强大的计算能力,满足高性能需求。
小型项目与快速开发:
- 选择:
Brain.js
- 理由:适合快速构建原型和简单的神经网络任务,易于维护,开发效率高。
大规模应用与高性能需求:
- 选择:
TensorFlow.js
- 理由:其强大的加速能力和复杂模型支持,使其成为生产级应用的理想选择,尤其适合需要 GPU 加速的大规模任务。
结语
通过本文的对比,读者可以清晰了解 TensorFlow.js
和 Brain.js
在功能、性能、学习曲线、适用场景等方面的显著差异。选择最适合的库时,需要综合考虑项目的复杂度、团队的技术背景以及性能需求。
如果你的项目需要处理复杂的深度学习任务,并且需要高性能计算与广泛的社区支持,TensorFlow.js
是不二之选。它功能强大、生态丰富,适合图像识别、自然语言处理等高需求场景。而如果你只是进行小型神经网络实验,或需要快速原型开发,Brain.js
提供了更简洁易用的解决方案,是初学者和小型项目开发者的理想选择。
无论选择哪个库,充分了解它们的优势与限制,将帮助你在项目开发中高效使用这些工具,成功实现你的前端 AI
开发目标。
附录:对比表格
以下对比表格总结了 TensorFlow.js
和 Brain.js
在关键维度上的差异,帮助读者快速决策:
特性 | TensorFlow.js | Brain.js |
---|---|---|
GitHub 星标数量 | 18.6K | 14.5K |
功能复杂度 | 高,支持复杂的深度学习模型(CNN , RNN , GAN 等) | 低,支持基础前馈神经网络和LSTM 网络 |
学习曲线 | 陡峭,适合有深度学习经验的开发者 | 平缓,适合初学者和快速原型开发 |
使用场景 | 复杂场景,如大规模数据处理、图像识别、语音处理等 | 小型项目,如简单分类任务、时间序列预测 |
支持的模型类型 | 多种类型(CNN , RNN , GAN 等复杂模型) | 基础类型(前馈神经网络、LSTM 等) |
性能优化 | 支持 WebGL 加速和 GPU 并行计算,适合高性能需求 | 不支持硬件加速,适合小规模数据处理 |
开发平台 | 浏览器和 Node.js 环境,跨平台支持 | 主要用于浏览器,也支持 Node.js |
社区支持与文档 | 丰富的生态系统,拥有大量教程、示例和预训练模型资源 | 社区较小但活跃,文档简单直观 |
易用性 | API 较复杂,适合有深度学习背景的开发者 | API 简洁,适合初学者和快速开发 |
适用开发者 | 高阶开发者,有深度学习基础 | 初学者及快速实现简单任务的开发者 |
体积与资源消耗 | 库文件较大,可能影响加载速度 | 体积小,对网页性能影响较小 |
训练与推理能力 | 支持复杂模型的训练与推理,适合高需求场景 | 适合简单任务的训练与推理 |
预训练模型支持 | 支持从 TensorFlow Hub 加载预训练模型 | 不支持广泛预训练模型,主要用于自定义训练 |
同系列文章推荐
如果你觉得本文对你有所帮助,不妨看看以下同系列文章,深入了解 AI 开发的更多可能性:
欢迎点击链接阅读,开启你的前端 AI
学习之旅,让开发更高效、更有趣! 🚀
我是 “一点一木”
专注分享,因为分享能让更多人专注。
生命只有一次,人应这样度过:当回首往事时,不因虚度年华而悔恨,不因碌碌无为而羞愧。在有限的时间里,用善意与热情拥抱世界,不求回报,只为当回忆起曾经的点滴时,能无愧于心,温暖他人。
来源:juejin.cn/post/7459285932092211238