注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Chrome AI:颠覆网页开发的全新黑科技

web
Chrome AI 长啥样 废话不多说,让我们直接来看一个示例: async function askAi(question) { if (!question) return "你倒是输入问题啊" // 检查模型是否已下载(模型只需下载一次,就可以供所有...
继续阅读 »

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


准备工作



  1. 下载最新 Chrome Dev 版或 Chrome Canary 版。(版本号不低于 128.0.6545.0)

  2. 确保你的电脑有 22G 的可用存储空间。

  3. 很科学的网络


启用 Gemini Nano 和 Prompt API



  1. 打开 Chrome, 在地址栏输入: chrome://flags/#optimization-guide-on-device-model,选择 enable BypassPerfRequirement,这步是绕过性能检查,确保 Gemini Nano能顺利下载。

  2. 再输入 chrome://flags/#prompt-api-for-gemini-nano,选择 enable

  3. 重启 Chrome 浏览器。


确认 Gemini Nano 是否可用



  1. F12 打开开发者工具, 在控制台输入 await window.ai.canCreateTextSession(),如果返回 readily,就说明 OK 了。

  2. 如果上面的步骤不成功,重启 Chrome 后继续下面的操作:



    • 新开一个标签页,输入 chrome://components

    • 找到 Optimization Guide On Device Model,点击 Check for update,等待一个世纪直到 Status - Component updated 出现就是模型下载完成。(模型版本号不低于 2024.5.21.1031



  3. 模型下载完成后, 再次在开发者工具的控制台中输入await window.ai.canCreateTextSession(),如果这次返回 readily,那就 OK 了。

  4. 如果还是不行,可以等一会儿再试。多次尝试后仍然失败,请关闭此文章🐶。


思考


AI 最近两年可谓是爆发式增长,从 GPT-3 开始,笔者就一直在使用 AI 产品,如 Github copilotChatGPT 推出后,我迅速开发了一个 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 插件

web
那天下午,我正在浏览一些到岛国前端技术文档,突然听到身后传来脚步声。我下意识地想要切换窗口,但已经来不及了——我的同事小张已经站在了我身后。"咦,你在看什么?"他好奇地问道。我尴尬地笑了笑,手忙脚乱地想要关闭页面。那一刻,我多么希望有一个快捷键,能瞬间让整个屏...
继续阅读 »

那天下午,我正在浏览一些到岛国前端技术文档,突然听到身后传来脚步声。我下意识地想要切换窗口,但已经来不及了——我的同事小张已经站在了我身后。"咦,你在看什么?"他好奇地问道。我尴尬地笑了笑,手忙脚乱地想要关闭页面。那一刻,我多么希望有一个快捷键,能瞬间让整个屏幕变得模糊,这样就不会有人看到我正在浏览的内容了。


于是乎我想:为什么不开发一个 Chrome 插件,让用户能够一键模糊整个网页呢?这样不仅能保护隐私,还能避免类似的尴尬情况。


开发过程


说干就干,我开始了 Web Blur 插件的开发。这个插件的核心功能很简单:



  1. 一键切换:使用快捷键(默认 Ctrl+B)快速开启/关闭模糊效果



  1. 可调节的模糊程度:根据个人喜好调整模糊强度



  1. 记住设置:自动保存用户的偏好设置


技术实现


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. 用户界面


  <button id="toggleBlur" class="toggle-button">
Toggle Blur
</button>

<div class="slider-container">
<label>Blur Amount:</label>
<input type="range" id="blurAmount" min="0" max="20" value="5">
<span id="blurValue">5px</span>
</div>

<div class="shortcut-info">
Current: Ctrl+Shift+B
<button id="changeShortcut">Change shortcut</button>
</div>
</div>

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);
}
});
}

image.png


以后可以愉快的学技术辣


作者:想想肿子会怎么做
来源:juejin.cn/post/7509042833152851978
收起阅读 »

被问tsconfig.json 和 tsconfig.node.json 有什么作用,我懵了……

web
背景 事情是这样的,前几天在项目例会上,领导随口问了我我一个看似简单的问题: “我们项目里有tsconfig.json 和 tsconfig.node.json ,它们有什么作用?” 活久见,我从来没注意过这个细节,我内心无语,问这种问题对项目有什么用!但机...
继续阅读 »

背景


事情是这样的,前几天在项目例会上,领导随口问了我我一个看似简单的问题:


“我们项目里有tsconfig.jsontsconfig.node.json ,它们有什么作用?”



活久见,我从来没注意过这个细节,我内心无语,问这种问题对项目有什么用!但机智的我还是回答上来了:不都是typescript的配置文件么。


领导肯定了我的回答,又继续问,那为什么项目中有两个配置文件呢?我机智的说,我理解的不深,领导您讲讲吧,我学习一下。


tsconfig.json 是干嘛的?


说白了,tsconfig.json 就是 告诉 TypeScript:我要用哪些规则来“看懂”和“检查”我写的代码。


你可以把它想象成 TypeScript 的“眼镜”,没有它,TS 编译器就会“看不清楚”你的项目到底该怎么理解、怎么校验。



  • 影响代码能不能被正确编译


如果我们用了某些新语法(比如 optional chainingimport 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,其中用到了 fspathurl 等 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.jsontsconfig.node.json
目标环境浏览器(前端代码)Node.js(构建脚本、配置文件)
类型声明支持浏览器相关,通常不包含 node类型显式包含 node类型
使用场景项目源码、页面组件、前端逻辑vite.config.ts、开发工具脚本、构建相关逻辑
典型依赖项Vue 类型(如 vue, @vue/runtime-domNode 类型(如 fs, path
是否必须存在是,TypeScript 项目基本都要有否,但推荐拆分使用以清晰职责
是否引用主配置通常是主配置可通过 tsconfig.jsonreferences引用它

作者:快乐就是哈哈哈
来源:juejin.cn/post/7500130421608579112
收起阅读 »

京东鸿蒙上线前瞻——使用 Taro 打造高性能原生应用

web
背景 2024 年 1 月,京东正式启动鸿蒙原生应用开发,基于 HarmonyOS NEXT 的全场景、原生智能、原生安全等优势特性,为消费者打造更流畅、更智能、更安全的购物体验。同年 6 月,京东鸿蒙原生应用尝鲜版上架华为应用市场,计划 9 月完成正式版的上...
继续阅读 »

背景


2024 年 1 月,京东正式启动鸿蒙原生应用开发,基于 HarmonyOS NEXT 的全场景、原生智能、原生安全等优势特性,为消费者打造更流畅、更智能、更安全的购物体验。同年 6 月,京东鸿蒙原生应用尝鲜版上架华为应用市场,计划 9 月完成正式版的上架。


配图2.png


早在 2020 年,京东与华为就签署了战略合作协议,不断加大技术投入探索 HarmonyOS 的创新特性。作为华为鸿蒙生态的首批头部合作伙伴,在适配鸿蒙操作系统的过程中,京东与华为一直保持着密切的技术沟通与共创,双方共同攻坚行业适配难点,并推动多端统一开发解决方案 Taro 在业界率先实现对鸿蒙 ArkUI 的原生开发支持。


本文将阐述京东鸿蒙原生应用在开发时所采用的技术方案、技术特点、性能表现以及未来的优化计划。通过介绍选择 Taro 作为京东鸿蒙原生应用的开发框架的原因,分析 Taro 在支持 Web 范式开发、快速迁移存量项目、渲染性能优化、高阶功能支持以及混合开发模式等方面的优势。


技术方案


京东在开发鸿蒙原生应用的过程中,需要考虑如何在有限的时间内高效完成项目,同时兼顾应用的性能与用户体验。为了达成这一目标,选择合适的技术方案至关重要。


在技术选型方面,开发一个鸿蒙原生应用,一般会有两种选择:



  • 使用原生 ArkTS 进行鸿蒙开发

  • 使用跨端框架进行鸿蒙开发


使用原生 ArkTS 进行鸿蒙开发,面临着开发周期冗长、维护多端多套应用代码成本高昂的挑战。在交付时间紧、任务重的情况下,京东果断选择跨端框架来开发鸿蒙原生应用,以期在有限的时间内高效完成项目。


作为在业界具备代表性的开源跨端框架之一,Taro 是由京东凹凸实验室团队开发的一款开放式跨端跨框架解决方案,它支持开发者使用一套代码,实现在 H5、小程序以及鸿蒙等多个平台上的运行。


通过 Taro 提供的编译能力,开发者可以将整个 Taro 项目轻松地转换为一个独立的鸿蒙应用,无需额外的开发工作。


image.png


另外,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 文件的编译和解析速度


image.png


(图片来自 LightningCSS 官网)


在运行时上,我们参考了 WebKit 浏览器内核的处理流程,对于 CSS 规则的匹配和标脏进行了架构上的升级,大幅提升了 CSS 应用和更新的性能。


image.png


支持存量 Taro 项目的快速迁移


将现有业务适配到一个全新的端侧平台,无疑需要投入大量的人力物力。而 Taro 框架的主要优势,正是能够有效解决这种跨端场景下的项目迁移难题。通过 Taro,我们可以以极低的成本,在保证高度还原和高性能的前提下,快速地将现有的 Taro 项目迁移到鸿蒙系统上。


image.png


渲染性能比肩原生开发


在 Taro 转换鸿蒙原生页面的技术实现上,我们摒弃了之前使用 ArkTS 原生组件递归渲染节点树的方案将更多的运行时逻辑如组件、动效、测算和布局等逻辑下沉到了 C++ 层,极大地提升了页面的渲染性能。


另外,我们对于 Taro 项目中 CSS 样式的处理架构进行了一次整体的重构和升级,并引入布局引擎Yoga,将页面的测量和布局放在 Taro 侧进行实现,基于这些优化,实现一套高效的渲染任务管线,使得 Taro 开发的鸿蒙页面在性能上足以和鸿蒙 ArkTS 原生页面比肩。


image.png


支持虚拟列表和节点复用等高阶功能


长列表渲染是应用开发普遍会遇到的场景,在商品列表、订单列表、消息列表等需要无限滚动的组件和页面中广泛存在,这些场景如果不进行特殊的处理,只是单纯对数据进行渲染和更新,在数据量非常大的情况下,可能会引发严重的性能问题,导致视图在一段时间内无法响应用户操作。


在这个背景下,Taro 在鸿蒙端提供了长列表类型组件(WaterFlow & List) ,并对长列表类型组件进行了优化,提供了懒加载、预加载和节点复用等功能,有效地解决大数据量下的性能问题,提高应用的流畅度和用户体验。


image.png


(图片来自 HarmonyOS 官网)


支持原生混合开发等多种开发模式


Taro 的组件和 API 是以小程序作为基准来进行设计的,因此在实际的鸿蒙应用开发过程中,会出现所需的组件和 API 在 Taro 中不存在的情况,因为针对这种情况,Taro 提供了原生混合开发的能力,支持将原生页面或者原生组件混合编译到 Taro 鸿蒙项目中,支持 Taro 组件和鸿蒙原生组件在页面上的混合使用


image.png


性能表现


京东鸿蒙原生应用性能数据


经过对 Taro 的屡次优化和打磨,使得京东鸿蒙原生应用取得了优秀的性能表现,最终首页的渲染耗时 1062ms,相比于之前的 ArkTS 版本,性能提升了 23.9% ;商详的渲染耗时 560 ms,相比于之前的 ArkTS 版本,性能提升 74.2%


值得注意的是商详页性能提升显著,经过分析发现商详楼层众多,CSS 样式也复杂多样,因此在 ArkTS 版本中,在 CSS 的解析和属性应用阶段占用了过多的时间,在 CAPI 版本进行了CSSOM 模块的架构升级后,带来了明显的性能提升。


iShot_2024-09-03_22.57.29.png


基于 Taro 开发的页面,在华为性能工厂的专业测试下,大部分都以优异的成绩通过了性能验收,充分证明了 Taro 在鸿蒙端的高性能表现。


总结和未来展望


Taro 目前已经成为一个全业务域的跨端开发解决方案,实现 Web 类(如小程序、Hybrid)和原生类(iOS、Android、鸿蒙)的一体化开发,在高性能的鸿蒙适配方案的加持下,业务能快速拓展到新兴的鸿蒙系统中去,可以极大满足业务集约化开发的需求。


未来计划


后续,Taro 还会持续在性能上进行优化,以更好地适配鸿蒙系统:



  • 将开发者的 JS 业务代码和应用框架层的 JS 代码与主线程的 UI 渲染逻辑分离,另起一条 JavaScript 线程,执行这些 JS 代码,避免上层业务逻辑堵塞主线程运行,防止页面出现卡顿、丢帧的现象。


image.png



  • 实现视图节点拍平,将不影响布局的视图节点进行整合,减少实际绘制上屏的页面组件节点数量,提升页面的渲染性能。


image.png


(图片来自 React Native 官网)



  • 实现原生性能级别的动态更新能力,支持开发者在不重新编译和发布应用的情况下,动态更新应用中的页面和功能。


总结


京东鸿蒙原生应用是 Taro 打响在鸿蒙端侧适配的第一枪,证明了 Taro 方案适配鸿蒙原生应用的可行性。这标志着 Taro 在多端统一开发上的新突破,意味着 Taro 将为更多的企业和开发者提供优秀的跨端解决方案,使开发者能够以更高的效率开发出适配鸿蒙系统的高性能应用。


作者:京东零售技术
来源:juejin.cn/post/7412486655862571034
收起阅读 »

harmony-安装app的脚本

web
概述 现在鸿蒙手机安装harmony.app包有很大限制,他并不能像Android那样直接在手机上安装apk,也不像IOS可以传多个ipa,AppGallery Connect只能同时上传3个包,也就是说QA只能同时测3个不同的包,这样大大限制了开发效率和测试...
继续阅读 »

概述


现在鸿蒙手机安装harmony.app包有很大限制,他并不能像Android那样直接在手机上安装apk,也不像IOS可以传多个ipa,AppGallery Connect只能同时上传3个包,也就是说QA只能同时测3个不同的包,这样大大限制了开发效率和测试效率,大致解决方案



  1. 给QA开通git权限,下载 DevEco Studio ,让QA直接run对应的分支的包

  2. 直接让QA拿着手机让研发给跑包

  3. 上传AppGallery Connect,华为审核通过之后,直接扫码安装,缺点是:只能同时测试3个,还需要审核


但是上面这三种方案都对研发和QA不太友好,所以悄悄的写了个安装脚本,让QA直接运行即可,



  • 无需配置hdc环境变量

  • 无需下载DevEco Studio

  • 像Android 使用 adb 安装apk一样

  • 脚本下载hdc文件只有1.7M

  • 如果需要手动签名额外需要hap-sign-tool.jar和java两个文件【不建议在脚本中手动签名,请在打包服务器中,比如jenkins】,当然了测试签名还是可以放在脚本中的


编写 Shell 脚本,先上图


成功
image.png
错误


image.png


首先在 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


咱们可以根据上面的流程大致写一下


脚本方案



  1. 检测hdc文件是否存在,不存在使用cur下载

  2. 检测是否连接手机,并且只有一个手机

  3. 检测传入app的路径是否存是以.app结尾,并且文件存在

  4. 创建手机端临时目录

  5. 解压.app到电脑端,复制里面的所有hsp和hap到 临时目录,如果需要手动签名可以在这一步去签名

  6. 安装临时目录的所有文件

  7. 删除手机临时目录以及电脑端解压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}"


对hsp和hap签名


手动签名需要hap-sign-tool.jar,在command-line-tools/sdk/default/openharmony/toolchains/libs/hap-sign-tool.jar并且需要java文件,也都放到项目中了
在脚本中解压app之后,发送到手机之前,对所有的hsp和hap签名


image.png
代码如下


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.表明你的真机需要在添加你的设备,参考注册调试设备


image.png


这是真机的效果


在shell文件夹运行 ./install.sh ./InstallApp-default-signed.app


image.png


使用我demo,如果要写手动签名脚本,需要更换sign文件夹,自己去申请签名,并且要更换bundleName,因为你的设备并没有在我的华为Profile里面添加


作者:android大哥
来源:juejin.cn/post/7438456086308651045
收起阅读 »

横扫鸿蒙弹窗乱象,SmartDialog出世

web
前言 但凡用过鸿蒙原生弹窗的小伙伴,就能体会到它们是有多么的难用和奇葩,什么AlertDialog,CustomDialog,SubWindow,bindXxx,只要大家用心去体验,就能发现他们有很多离谱的设计和限制,时常就是一边用,一边骂骂咧咧的吐槽 实属无...
继续阅读 »

前言


但凡用过鸿蒙原生弹窗的小伙伴,就能体会到它们是有多么的难用和奇葩,什么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 模拟器目前有些问题,会导致动画闪烁,请忽略;注:真机动画丝滑流畅,无任何问题


attachLocation


customTag


customJumpPage


极简用法


// 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())
}

customUseArgs


多位置弹窗


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())
}

customLocation


跨页面交互



  • 正常使用,无需设置什么参数


export function customJumpPage() {
SmartDialog.show({
builder: dialogJumpPage,
})
}

@Builder
function dialogJumpPage() {
Text("JumPage")
.fontSize(30)
.padding(50)
.borderRadius(12)
.fontColor(Color.White)
.backgroundColor(randomColor())
.onClick(() => {
// 跳转页面
})
}

customJumpPage


关闭指定弹窗


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)
}

customTag


自定义遮罩


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())
}

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())
}

attachEasy


多方向定位


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())
}

attachLocation


Loading


对于Loading而言,应该有几个比较明显的特性



  • loading和dialog都存在页面上,哪怕dialog打开,loading都应该显示dialog之上

  • loading应该具有单一特性,多次打开loading,页面也应该只存在一个loading

  • 刷新特性,多次打开loading,后续打开的loading样式,应该覆盖之前打开的loading样式

  • loading使用频率非常高,应该支持强大的拓展和极简的使用


从上面列举几个特性而言,loading是一个非常特殊的dialog,所以需要针对其特性,进行定制化的实现


当然了,内部已经屏蔽了细节,在使用上,和dialog的使用没什么区别


默认loading


SmartDialog.showLoading()

loadingDefault


自定义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())
}

loadingCustom


最后


鸿蒙版的SmartDialog,相信会对开发鸿蒙的小伙伴们有一些帮助~.~


现在就业环境真是让人头皮发麻,现在的各种技术群里,看到好多人公司各种拖欠工资,各种失业半年的情况


淦,不知道还能写多长时间代码!


004B5DB3


作者:小呆呆666
来源:juejin.cn/post/7401056900878368807
收起阅读 »

鸿蒙Next DevEco Studio开启NewUI

web
众所周知,DevEco也是基于Jetbrain的IntelliJ IDEA社区版开发,所以原则上也是可以开启NewUI的。 开了NewUI的样子: 没开NewUI的样子: 从我的审美来说,我还是比较喜欢开了NewUI的样子😁。 如何开始NewUI 双击sh...
继续阅读 »

众所周知,DevEco也是基于Jetbrain的IntelliJ IDEA社区版开发,所以原则上也是可以开启NewUI的。


开了NewUI的样子:


image.png


没开NewUI的样子:


image.png


从我的审美来说,我还是比较喜欢开了NewUI的样子😁。


如何开始NewUI


双击shift打开搜索窗口,输入Registry,然后打开。接着打开experimental属性中的ui部分就可以了,最后只需要重启,你就能愉快的写代码🌶。


image.png


image.png


作者:simplepeng
来源:juejin.cn/post/7406538050228764713
收起阅读 »

对比Swift和ArkTS,鸿蒙开发可以这样做

web
最近在学 ArkTS UI 发现其与 Swift UI 非常像,于是做了一下对比,发现可探索性很强。 Hello World 代码对比 从 Hello World 来看两者就非常像。 鸿蒙 ArtTs UI 的 Hello World 如下: @Entry ...
继续阅读 »

最近在学 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")) // 选中的背景颜色区别一般的
...
}

前端的解耦合最终还是需要靠组件化、高阶函数来完成:



  1. 组件化:通过将 UI 分解为独立的组件,每个组件都有自己的功能和状态,可以进一步降低耦合性。组件化使得开发者可以独立地开发和测试组件,而不需要关心其他部分的实现。

  2. 高阶函数:在某些声明式 UI 框架中,可以使用高阶函数来复用共有逻辑,同时允许替换独有逻辑。这种方式可以减少代码的重复,并提高组件的可重用性。


组件差异


SwiftUI 和鸿蒙操作系统的 ArtTS UI 框架都提供了多种组件,按前端使用情况,其实同样有很多相同之处。


基础组件


基础组件基本相同:



  • Text 用于显示文本;

  • Image 用于显示图片;

  • Button 用户可以点击执行操作;


布局组件


Swift UI 和 ArkTS 相同/相似的布局组件有:


Swift UIArkTS说明
HStackRow水平堆栈,用于水平排列子视图
VStackColumn垂直堆栈,用于垂直排列子视图
ZStackStack堆栈视图,用于堆叠多个视图
SpacerBlank空白组件,用于占据剩余空间
ScrollViewScroll滚动视图,允许用户滚动内容
TabsViewTab标签视图,用于创建标签式导航
NavigationViewNavigation导航视图,用于创建导航结构

可以看出,两者基本上的布局都可以通用,当然细节上肯定会有很多不同。不过做一个转译应该不难,可以尝试使用 AI 来完成。


不同的地方在于 Flex 和 Grid 布局:



  1. Swift UI 仅有懒加载的组件:LazyVGridLazyHGrid:懒加载的网格视图,用于展示大量数据。

  2. Ark TS UI有的组件:Flex以弹性方式容器组件:用于灵活布局;Grid网格布局组件:用于创建网格布局。


SwiftUI的布局系统非常灵活,可以通过调整alignmentspacingpadding等属性来实现复杂的布局需求。虽然它不完全等同于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 转译


通过这次对比学习,可以得出以下结论:



  1. 声明式 UI 是前端更容易配置与阅读,尤其是 ArkTS ;

  2. 解耦合需要运用组件化、高阶函数等知识进行自行处理;

  3. ArkTS UI 和 Swift UI 的基础组件、布局组件相似度非常高,基本能一一对应,可以对照学习使用;

  4. 鉴于两者相似度高,可以尝试开发一个 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

web
跨窗口通信的九重天劫:从postMessage到BroadcastChannel 第一重:postMessage 基础劫 —— 安全与效率的平衡术 // 父窗口发送 const child = window.open('child.html'); child...
继续阅读 »

跨窗口通信的九重天劫:从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);
});

安全守则



  1. 始终验证origin属性

  2. 敏感数据使用JSON.stringify + 加密

  3. 使用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事件]



天劫问答



  1. 如何防止跨窗口消息风暴?



    • 采用消息节流(throttle)

    • 使用window.performance.now()标记时序

    • 实施优先级队列



  2. 哪种方式最适合微前端架构?



    • BroadcastChannel全局通信 + postMessage父子隔离



  3. 如何实现跨源安全通信?



    • 使用iframe作为代理中继

    • 配合CORS和document.domain设置






调试工具推荐



  1. Charles - 抓取WebSocket消息

  2. Window Query - 查看所有窗口对象

  3. Postman - 模拟SSE事件流


性能检测代码


// 通信延迟检测
const start = performance.now();
channel.postMessage('ping');
channel.onmessage = () => {
console.log('往返延迟:', performance.now() - start);
};

作者:ErpanOmer
来源:juejin.cn/post/7498619063671046196
收起阅读 »

你可能不知道的前端18个冷知识

web
今天带大家盘点一下前端的一些冷知识。 一、浏览器地址栏的妙用 1.1 可以执行javascript代码 在地址栏中输入javascript:alert('hello world'),然后按回车键,会弹出一个提示框显示hello world。 注意:如果直接把...
继续阅读 »

今天带大家盘点一下前端的一些冷知识。


一、浏览器地址栏的妙用


1.1 可以执行javascript代码


在地址栏中输入javascript:alert('hello world'),然后按回车键,会弹出一个提示框显示hello world



注意:如果直接把这段代码复制到地址栏,浏览器会删除掉前面javascript:(比如谷歌浏览器、edge浏览器等),需要自己手动加上。



还可以使用location.hrefwindow.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快捷键和右键菜单,用户依然可以通过其它方式打开控制台。



  1. 通过浏览器菜单选项直接打开控制台:比如 chrome浏览器通过 菜单 > 更多工具 > 开发者工具 路径可以打开控制台,Firefox/Edge/Safari 等浏览器都有类似选项。

  2. 用户还可以通过其它快捷键打开控制台:



  • 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


aInfinity无穷大时,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的使用


datalistHTML5 中引入的一个新元素,它用于为<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是一个简写属性,用于同时设置元素的 toprightbottomleft 属性


.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 比较

web
Day.js 与 Moment.js 的比较 优点 体积小:Day.js 的体积仅为 2KB 左右,而 Moment.js 的体积约为 67KB。 API 相似:Day.js 的 API 与 Moment.js 高度相似,迁移成本低。 不可变性:Day.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


image.png
- 老牌时间处理库,2012 年发布,曾是 JavaScript 时间处理的事实标准,功能全面且语法直观。
- 设计目标:覆盖几乎所有时间处理需求,包括复杂的时区、本地化、格式化、操作等。
- 现状:2020 年进入 维护模式(不再新增功能,仅修复严重 bug),官方推荐迁移至更现代的库(如 Day.js、Luxon 等)。


image.png



  • Day.js



    • 轻量替代方案,2018 年发布,设计灵感直接来源于 Moment.js,语法高度相似,但更简洁轻量。

    • 设计目标:通过最小化核心功能 + 插件机制,提供常用时间操作能力,避免过度设计。

    • 现状:持续活跃更新,由单一开发者维护,社区支持度快速增长。





核心差异对比


维度Moment.jsDay.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+(如 PromiseProxy),支持现代浏览器(Chrome 49+, Firefox 52+, 等),不支持 IE。
生态与社区生态成熟,周边工具丰富(如 Webpack 插件、React 组件等),但更新停滞。生态快速发展中,主流框架(如 Vue、React)适配良好,插件系统完善(官方维护 20+ 插件)。
维护状态进入维护模式,仅安全更新,无新功能。活跃维护,定期发布新版本,快速响应社区需求。

Dayjs中文文档


dayjs.uihtm.com/


如何将 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」?

web
声明式UI && 命令式UI 传统的命令式UI编程范式中,开发者需要明确地指示系统如何一步一步地构建和更新UI,手动处理每一个UI更新和状态变化,随着应用复杂度增加,管理UI和状态同步变得更加困难。所以声明式UI应运而生,它的出现就是为了简化U...
继续阅读 »

声明式UI && 命令式UI


传统的命令式UI编程范式中,开发者需要明确地指示系统如何一步一步地构建和更新UI,手动处理每一个UI更新和状态变化,随着应用复杂度增加,管理UI和状态同步变得更加困难。所以声明式UI应运而生,它的出现就是为了简化UI开发,减少手动管理状态和UI更新的复杂性。现代前端框架(Jetpack Compose、SwiftUI)都采用了声明式UI的编程范式。


在声明式UI编程范式中,开发者不再手动构建、更新UI,而是「描述界面应该是什么样子的」:开发者定义界面状态,然后框架会根据状态自动更新UI。


相对于命令式UI,声明式UI更加简洁和易于维护,但缺乏了灵活性——开发者无法完全控制UI更新的粒度。所以声明式UI的性能是一大挑战,尤其是复杂长列表场景下的性能问题。


为了解决长列表的渲染问题,Jetpack Compose 提供了LazyColumnLazyRow等组件,SwiftUI也有ListLazyVStack等组件。作为鸿蒙系统的UI体系ArkUI自然也有用于长列表的组件LazyForEach



LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。



LazyForEach用法


本文就针对ArkUI中的LazyForEach来探究一二。


LazyForEach 的渲染依赖IDataSourceDataChangeListener,我们一个一个来看下:


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时需要注意:




  1. onDatasetChange与其它操作数据的接口不能混用。

  2. 传入onDatasetChange的operations,其中每一项operation的index均从修改前的原数组内寻找。因此,opeartions中的index跟操作Datasource中的index不是一一对应的。

  3. 调用一次onDatasetChange,一个index对应的数据只能被操作一次,若被操作多次,LazyForEach仅使第一个操作生效。

  4. 部分操作可以由开发者传入键值,LazyForEach不会再去重复调用keygenerator获取键值,需要开发者保证传入的键值的正确性。

  5. 若本次操作集合中有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
收起阅读 »

中原银行鸿蒙版开发实战

web
一、建设背景     2024年1月18日,HarmonyOS NEXT鸿蒙星河版亮相,标志着“纯血鸿蒙”正式开始扬帆起航。同年6月21日,在华为开发者大会上HarmonyOS Next正式发布,并且将于第4季度发布商用版。     中原银行App用户中华为机...
继续阅读 »

一、建设背景


    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”类设备),实现金融服务在各类智能终端上的全面覆盖与深度融合。银行网点服务侧,我们将结合鸿蒙实况窗技术,实现客户在网点排队取号时,可通过手机或智能手表实时查看排队进度,甚至提前线上完成部分业务预办理,提升服务效率与用户体验。此外,通过对接鸿蒙的意图框架,智能识别用户的信用卡还款需求,自动推送还款提醒,减少逾期风险;同时,基于用户的地理位置等信息,精准推送本地化的金融产品与服务,实现金融服务的个性化与精准化。


作者:跟着感觉走2024
来源:juejin.cn/post/7403606017308082226
收起阅读 »

11 个 JavaScript 杀手脚本,用于自动执行日常任务

web
作者:js每日一题 今天这篇文章,我将分享我使用收藏的 11 个 JavaScript 脚本,它们可以帮助您自动化日常工作的各个方面。 1. 自动文件备份 担心丢失重要文件?此脚本将文件从一个目录复制到备份文件夹,确保您始终保存最新版本。 const fs =...
继续阅读 »

作者: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 nodemailerrequire('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 PDFDocumentrequire('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: [500700],          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,      soundtrue// 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 PDFMergerrequire('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();

最后,感谢您一直阅读到最后!希望今天内容能够帮助到你,如果你喜欢此内容的话,也请分享给你的小伙伴,也许能够帮助到他们。


作者:独立开阀者_FwtCoder
来源:juejin.cn/post/7502855221241888805
收起阅读 »

不用 js实现渐变、虚线、跑马灯、可伸缩边框

web
最近遇到个需求,要求实现一个渐变色的边框,并且是虚线的,同时还要有动画。 有的朋友可能看到这里就要开骂了,估计要提刀找设计和产品怼回去了。 但其实我是可以理解的,因为这种花哨的边框想要用在一个类似于魔法框的地方,框住一个地方,然后交给 ai 处理。这样的交互设...
继续阅读 »

最近遇到个需求,要求实现一个渐变色的边框,并且是虚线的,同时还要有动画。


有的朋友可能看到这里就要开骂了,估计要提刀找设计和产品怼回去了。


但其实我是可以理解的,因为这种花哨的边框想要用在一个类似于魔法框的地方,框住一个地方,然后交给 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>

现在长这样
image.png


接下来给 rect 设置填充和描边,边框宽度为 4px


<rect 
fill="transparent"
stroke="red"
stroke-width="4"
width="100%"
height="100%"
>
</rect>

image.png


接下来我们给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>

image.png


接下通过 stroke-dasharray 来设置虚线边框


mdn 上关于 dasharry的介绍在这里 developer.mozilla.org/zh-CN/docs/…


image.png


我给 rect 设置 dasharray 为 5,5


<rect 
fill="transparent"
stroke="url(#gradient)"
stroke-dasharray="5,5"
stroke-width="4"
width="100%"
height="100%"
>
</rect>

image.png


这样渐变虚线边框就成了


接下来处理动画效果


动画分两种



  1. 线动,背景色不动

  2. 线不动,背景色动


这两种效果我都实现了


首先展示线动,背景色不动的情况


这种情况只要能想办法让虚线产生偏移就可以,于是我搜了一下,这不巧了吗,正好有个属性叫 stroke-dashoffset


image.png


于是就可以通过 css 动画来修改偏移量


<style>
.dashmove {
animation: dashmove 1s linear infinite;
}

@keyframes dashmove {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 10;
}
}
</style>
<rect class="dashmove" .... ></rect>

ezgif-1bb6c3542c4ad7.gif


大功告成


接下来处理第二种情况,线不动,背景动


这种情况就更简单了,因为 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>

ezgif-6b83b81feb0420.gif


接下来看一下拖拽的效果,这个很重要,因为我们不希望随着容器比例变化,会让边框宽度也变化。


给容器元素加上这三个属性,这个 div 就变成了可拖拽缩放的


.rect{
// ...
resize: both;
position: relative;
overflow: auto;
}

看下效果


ezgif-58de40b814d0bb.gif


完美 🎉🎉🎉


在这里查看完整在线 demo stackblitz.com/edit/stackb…


作者:阿古达木
来源:juejin.cn/post/7502127751572406323
收起阅读 »

鸿蒙UI通用代码几种抽离方法

web
    对于做APP的UI,免不了会写大量的重复布局,重复UI页面。此时对于将重复的UI控件抽离出来封装为通用组件来进行优化很是重要。     本文重点分析鸿蒙几种UI处理上,如何抽离通用方法来进行UI的复用。重点对比@Style,@Extend, Attri...
继续阅读 »

    对于做APP的UI,免不了会写大量的重复布局,重复UI页面。此时对于将重复的UI控件抽离出来封装为通用组件来进行优化很是重要。


    本文重点分析鸿蒙几种UI处理上,如何抽离通用方法来进行UI的复用。重点对比@Style,@Extend, AttributeModifier, @Builder和 struct 这五种方法的区别和使用场景。


Styles装饰器


    学过Android开发的小伙伴知道Android中有样式Style概念,我们定义好一个样式,那么就可以在各个组件中使用。从而保持每个组件有一样的属性。


    同理,鸿蒙中也可以使用样式。比如我们UI上的按钮具有相同的宽,高,背景色和边角距。那么我们就可以定义一个Style,每次定义按钮时候,只需要将Style赋给按钮就可以实现该属性,从而避免重复代码的书写。



  • 代码说明


image.png


    如图,在当前页面内定义一个方法,使用装饰器Styles修饰,修饰后,方法内部就可以直接通过 .属性 的方式来定义属性了。方法定义完后,下方button里可以直接使用该方法。虽然我这里方法命名为commonButton,但是实际上所有基础控件都可以使用该方法使用里边的属性,比如下方的Text组件。



  • Style特点



  1. 对于定义的方法,无法使用export修饰。
    这也就意味着,我们抽离的通用属性,只能在当前页面内的组件上使用,换个页面就不可以了,无法做到全局所有页面通用。

  2. 对于定义的方法,只能定义组件的通用属性。
    比如宽高,背景色等。对于一些控件特有属性是无法定义的。比如Select组件的selectedOptionFont特有属性无法定义。

  3. 方法不支持传递入参。
    意味着该样式无法做到动态修改,只要定义好就无法修改。比如定义好宽高为30,而某个组件宽要求为40,其他属性都不变,那这个方法也没法用了。

  4. 方法为组件通用属性,故所有组件都可以引用方法。


Extend装饰器


    对于Styles装饰器的第2点限制,鸿蒙推出了解决方案,那就是使用@Extend装饰器。


    Extend装饰器需要我们在使用时候指定定义哪个组件的属性,是专门抽离指定组件的。



  • 代码说明


image (1).png


    Extend要求必须定义方法为在当前文件的全局定义,且也不能够export,同时定义时候需要指定是针对哪个控件。如图指定了控件Select,然后就可以指定Select的专有属性了。



  • Extend特点



  1. 方法不支持export。
    和Styles一样,无法真正做到为所有页面抽离出都可用的属性方法。

  2. 方法只能定义为当前页面内的全局方法。
    一定程度上全局方法存在引用GlobalThis,具体副作用未知。

  3. 方法需要指定控件,其他控件无法使用,只能对专有控件做到了抽离

  4. 方法可以传入参。
    相比Styles, 可以在其他属性不变的情况下,只修改其中的部分属性。


AttributeModifier


    对于上述两个装饰器都存在一个相同的限制,就是无法做到全局所有文件都可以公用。


    AttributeModifier的出现可以变相的解决这个问题。AttributeModifier本意是用于自定义控件中设置属性用的。但是我们在这里也可以通过这个机制,来实现全局所有文件中控件均可通用的属性。



  • 代码说明


image (2).png


    该Modifier只能针对专用控件,比如我要抽离一个通用的TextInput,那么我可以如上图所定义。


    需要实现一个接口 AttributeModifier,接口泛型定义和我们想要给哪个控件使用有关,比如我们想给TextInput使用,那么泛型就是 TextInputAttribute,如果给Column使用,那么泛型就是ColumnAttribute,以此类推。


    在该接口的实现方法中,定义控件的属性。



  • 布局中使用


image (3).png



  • 自定义属性


    我们还可以自定义部分属性,只需要修改TextInputAttribute,例如我们想自定义字体大小。可以定义变量。


image (4).png



  • 使用


image (5).png



  • AttributeModifier特点



  1. 可以全局给所有页面中的控件使用

  2. 可以自定义任何控件中的属性,包括特有属性

  3. 可以通过修改代码做成链式调用

  4. 该方法需要new对象,比较笨重,需要书写较多代码


@Builder


    上述说的都是针对单独的控件,如果我们想抽离一个通用的布局呢?或者我们的控件就是固定的可以拿来到处使用。


    比如我有一个Text,各种属性固定,只是文案不同,那么我使用上述几种都比较麻烦,需要书写较多代码。那么这个时候就可以使用builder了。



  • 代码


image (6).png


    我们可以在任意需要展示该Text的地方使用,直接调用该方法,对应位置就可以显示出内容了。原理相当于是将方法内的控件代码放到了对应的位置上。



  • 使用


image (7).png



  • @Builder特点



  1. 定义好方法后,需要拿Builder装饰器修饰,可以在任何一个页面内调用方法使用。

  2. 可以通过方法传递入参

  3. 无法通过方法拿到控件对象,只能在方法里操作控件属性

  4. 除了单一控件,还可以定义布局,布局中存在多个控件的情况

  5. 轻量级

  6. 方法即拿即用,代码量少


struct


    有时候,我们可能页面中存在大量如下UI:


image (8).png


    对于这种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 来保存用户输入的数字,然后在用户点击按钮时候传给点击事件方法,交给调用者调用。



  • 使用


image (9).png


    点击设置按钮,点击事件触发,a直接赋值。



  • struct特点



  1. 可以封装复杂组件,自定义组件

  2. 可以维护变量存储用户输入输出

  3. 可以所有页面全局使用

  4. 可以自定义属性

  5. 无法链式设置属性


对比各个使用场景


    实际编程中,一般都是混合相互配合使用,没必要单独硬使用哪一个。



  1. style
    可以用来定义一些通用属性,比如背景色,边角据等

  2. Extend
    对于页面中一些特殊的控件,用的地方较多时候,可以抽离方法

  3. AttributeModifier
    如果Extend无法满足,那么选择这个

  4. Builder
    对于布局控件的属性变化不大,但是用的地方多时候使用,比如定义一个分割线。

  5. struct
    涉及到用户输入输出时候,相关控件可以抽离封装,避免页面内上方定义太多变量,不好维护。


作者:MinQ
来源:juejin.cn/post/7374293974577692706
收起阅读 »

Promise 引入全新 API!效率提升 300%!

web
来源:前端开发爱好者 在 JavaScript 的世界里,Promise 一直是处理异步操作的神器。 而现在,随着 ES2025 的发布,Promise 又迎来了一个超实用的新成员——Promise.try()! 这个新方法简直是对异步编程的一次 “革命” ,...
继续阅读 »

来源:前端开发爱好者


在 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:要包装的函数,可以是同步的,也可以是异步的。

  • arg1arg2、…、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() 的优势



  1. 统一处理同步和异步函数:不管函数是同步还是异步,Promise.try() 都能轻松搞定,代码更简洁。

  2. 异常处理:自动捕获同步异常,错误处理更直观,避免遗漏。

  3. 代码简洁:相比传统方法,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 的这个新特性,绝对值得你去尝试!快去试试吧,你的代码会变得更清晰、更强大!


作者:独立开阀者_FwtCoder
来源:juejin.cn/post/7494174524453158949
收起阅读 »

做Docx预览,一定要做这个神库!!

web
来源:沉浸式趣谈 只需几行代码,你就能在浏览器中完美预览 Word 文档,甚至连表格样式、页眉页脚都原汁原味地呈现出来。 接下来,给大家分享两个 Docx 预览的库: docx-preview VS mammoth docx-preview和mammoth是目...
继续阅读 »

来源:沉浸式趣谈


只需几行代码,你就能在浏览器中完美预览 Word 文档,甚至连表格样式、页眉页脚都原汁原味地呈现出来。


接下来,给大家分享两个 Docx 预览的库:


docx-preview VS mammoth


docx-previewmammoth是目前最流行的两个 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 中的<h1>标签。


哪个更适合你?


场景一:做了个简易 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',
            inWrappertrue,
            breakPagestrue,
            renderHeaderstrue,
            renderFooterstrue,
        });

        console.log('文档渲染成功!');
    } catch (error) {
        console.error('渲染文档时出错:', error);
    }
}

效果很赞!文档分页显示,目录、页眉页脚、表格边框样式都完美呈现。


不过也有些小坑:





    1. 文档特别大时,渲染速度会变慢





    1. 一些复杂的 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. 1. 语义化 HTML:生成干净的 HTML 结构

  2. 2. 样式映射:可以自定义 Word 样式到 HTML 元素的映射规则

  3. 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 或 <iframe> 实现 DOCX 的在线渲染。


示例代码:


<iframe src="https://view.officeapps.live.com/op/embed.aspx?src=文档URL"></iframe>

优点



  • • 格式高度还原:支持复杂排版、图表、公式等。

  • • 无需本地依赖:纯浏览器端实现。

  • • 官方维护:兼容性最好。


折腾一圈,还是docx-previewmammoth这俩兄弟最实用。


它们提供了轻量级的解决方案,仅需几十 KB 就能搞定 Word 预览问题,而且不需要依赖外部服务,完全可以在前端实现。



作者:独立开阀者_FwtCoder
来源:juejin.cn/post/7493733975779917861
收起阅读 »

产品小姐姐:地图(谷歌)选点,我还不能自己点?

web
💡 背景 最近在做海外项目,需要让用户选择一个实际地点——比如设置店铺位置、收货地址、活动举办地等。 我:不就是 uni.getLocation(object) 嘛,可惜海外项目用不了高德地图和百度地图,只能转向谷歌地图。 在@googlemaps/j...
继续阅读 »

💡 背景



最近在做海外项目,需要让用户选择一个实际地点——比如设置店铺位置、收货地址、活动举办地等。


我:不就是 uni.getLocation(object) 嘛,可惜海外项目用不了高德地图和百度地图,只能转向谷歌地图。



image.png



在@googlemaps/js-api-loader和vue3-google-map一顿折磨之后,不知道是不是使用方式错了,谷歌地图只在h5上显示,真机(包括自定义基座)都显示不了地图。无奈,只能转向WebView,至此,开始手撕谷歌地图: 地图选点 + 搜索地址 + 点击地图选点 + 经纬度回传



image.png




🎬 场景设定:选房子


某天产品说:“用户能搜地址,也能点地图,最后把这些地点存起来显示在地图。”


我听着简单,于是点开地图,灵光一闪:这不就是选房的逻辑吗?



  • 用户可以搜地段(搜索框)

  • 也可以瞎逛看到喜欢的(点击地图)

  • 最后点个确定,告诉中介(确认按钮)


我:“你疯啦?这是太平洋中间。”


产品:“不是,这是用户自由。”




🧱 核心结构分析


📦 页面骨架


<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 提供的地址搜索建议

  • 高级点:可以定位到建筑物级别的精度

  • 产品:用户脑子里比你更清楚他想去哪


地图1.gif




4. 点地图选点:给随性的人自由


map.addListener("click", (e) => {
const lat = e.latLng.lat();
const lng = e.latLng.lng();
...
});


  • 功能:用户随手一点,就能选中那个点

  • 技术点:用 Geocoder 反解析经纬度 ➜ 地址

  • 实用性:解决“我不知道地址叫什么”的痛点,且可切换卫星实景图像选点


就像: 当年你去面试,不知道公司叫什么,只知道“拐角有个便利店”。


地图2.gif




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,返回主流程


产品视角: 用户选完东西,你就别啰嗦了,自己退出。


微信图片_20250508174451.jpg


可查看卫星实景图像,点击地图选点


image.png


点击地图拿到地点数据就可以继续业务处理啦~




🎁 彩蛋动画: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 使用、移动端交互的多种协作。本文只写了大概方法思路,具体实现看具体业务需求。


下次再见!🌈


Snipaste_2025-04-27_15-18-02.png


作者:不爱说话郭德纲
来源:juejin.cn/post/7501649258279845939
收起阅读 »

Vue动态弹窗(Dialog)新境界:告别繁琐,拥抱优雅!🎉

web
写在开头 嘿,各位好呀!😀 今是2025年04月30日,明天就是五一假期了,激动的心从早上就一直没有沉静过,午休的时候闭着眼半小时硬是没睡着,哎,这班是一点也上不下去。 好!说回正题,本次要分享的是关于如何在Vue中比较优雅的调用弹窗的过程,请诸君按需食用哈...
继续阅读 »

写在开头


嘿,各位好呀!😀


今是2025年04月30日,明天就是五一假期了,激动的心从早上就一直没有沉静过,午休的时候闭着眼半小时硬是没睡着,哎,这班是一点也上不下去。


394674CE.jpg


好!说回正题,本次要分享的是关于如何在Vue中比较优雅的调用弹窗的过程,请诸君按需食用哈。


需求背景


最近,小编在捣鼓一个和低代码拖动交互类似的业务,说到低代码,大家肯定都不陌生吧❓像低代码表单、低代码图表平台等,用户可以通过简单的拖拽操作,像搭积木一样,快速"拼"出一个功能完善的表单页面,或者酷炫的数据可视化大屏。


而在这些低代码平台中,配置组件属性的交互方式通常有两种主流玩法:


其一,三栏式布局,左边是组件列表,中间是画布/预览区,右边是属性配置面板。选中中间画布的某个组件,右侧面板就自动显示它的配置项,如下:


image.png


其二,弹窗式配置,同样从左侧拖拽组件到画布,但选中组件后,通常会看到一个"设置"或"编辑"按钮。点击这个按钮,Duang~ ✨ 弹出一个专门的配置窗口 (Dialog),让你在里面集中完成所有设置。


image.png


这两种交互各有千秋,不评判好坏哈,反正合适自己业务场景的才是最好的。


然,今天咱们重点聚焦第二种:点击按钮弹出 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-Plusel-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.jsDialog/Panel.vue,它们俩的作用:



  • Panel.vue:负责"长什么样"和"填什么数据" 。

  • index.js:负责"怎么被调用"和"调用时带什么默认配置",并将 Panel.vue 包装后提供给外部使用。


具体实现


接下来,咱们就详细拆解一下这套新架构的设计具体代码实现过程。👇


但为了更好的讲述关键代码的实现,咱们不管拖动那块逻辑,仅通过点击按钮简单的来模拟,效果如下:


0430-01.gif

本次小编是新建了一个 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 会在你需要的时候,通过 createApph 函数,动态地创建一个包含 <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 文件,转而拥抱了基于 createApph 函数的动态创建方式。


这种新模式下,基础 Dialog、配置面板 ( Panel.vue )、以及调用逻辑各司其职,实现了真正的高内聚、低耦合。最终使得整个项目结构更加清晰、代码更加健壮,也极大地提升了后续的可维护性。希望这套方案能给你带来一些启发!


最后,如果你有任何疑问或者更好的想法,随时欢迎交流哦!👇









至此,本篇文章就写完啦,撒花撒花。


image.png


作者:橙某人
来源:juejin.cn/post/7498737799204093978
收起阅读 »

用canvas实现一个头像上的间断式的能量条

web
今天遇到一个很有意思的面试题,面试官给我一道题目,要我实现之前它们公司之前写的一个组件。 首先我介绍下这道题,首先我是先想到用flex布局来写头像分布,因为grid布局不能实现头像最后一排不能居中的效果。 然后这道题的重点来了,我一开始以为它头像上的边框是死...
继续阅读 »

今天遇到一个很有意思的面试题,面试官给我一道题目,要我实现之前它们公司之前写的一个组件。


e416716a-0b87-4411-ba39-7ec328968391.webp
首先我介绍下这道题,首先我是先想到用flex布局来写头像分布,因为grid布局不能实现头像最后一排不能居中的效果。


然后这道题的重点来了,我一开始以为它头像上的边框是死的,是张贴图。然后我去问面试官,他说是一个能量条,能根据投票的数量进行改变。我脑袋有点懵,问ai也没结果,生成的非常垃圾,然后就开始思考怎么才能实现。首先想到的是echats,但没有找到合适的,我就开始想echats是用canvas写的,我就想用canvas写下,在bilibili上看了下canvas的使用方法,于是就想到了这道题的解法。这是我的成果。


image.png
我就不做过多的讲解关于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>

image.png


成品代码


最后的成品我是用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实现钟表中刻度的实现,虽然没用它的方法,因为他的方法会导致刻度变形,不是扇形的能量条,但是它旋转坐标轴的想法让我大受启发。


作者:睡觉zzz
来源:juejin.cn/post/7501568955498070016
收起阅读 »

别再用 useEffect 写满组件了!试试这个三层数据架构 🤔🤔🤔

web
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎...
继续阅读 »

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777



我们常常低估了数据获取的复杂性,直到项目已经陷入困境。很多项目一开始只是简单地在组件中随手使用 useEffect()fetch()


然而不知不觉中,错误处理、加载状态、缓存逻辑和重复请求越堆越多,代码变得混乱难以维护,调试也越来越痛苦。


以下是我在许多项目中常见的一些问题:



  1. 组件触发了重复的网络请求 —— 只是因为没有正确缓存数据

  2. 组件频繁重渲染 —— 状态管理混乱,每秒更新几十次

  3. 过多的骨架屏加载效果 —— 导致整个应用看起来总是“卡在加载中”

  4. 用户看到旧数据 —— 修改数据后缓存没有及时更新

  5. 并发请求出现竞态条件 —— 不同请求返回顺序无法预测,导致数据错乱

  6. 内存泄漏 —— 订阅和事件监听未正确清理

  7. 乐观更新失败却悄无声息 —— 页面展示和实际数据不一致

  8. 服务端渲染数据失效太快 —— 页面跳转后立即变成过期数据

  9. 无效的轮询逻辑 —— 要么频繁请求没有变化的数据,要么根本没必要轮询

  10. 组件与数据请求逻辑强耦合 —— 导致组件复用性极差

  11. 顺序依赖的多层请求链 —— 比如:获取用户 → 用户所属组织 → 组织下的团队 → 团队成员


以上问题在复杂应用中极为常见。如果不从架构层面进行规划和优化,很容易陷入混乱的技术债中,影响项目长期维护与扩展。


这些问题会互相叠加。一个糟糕的数据获取模式,往往会引发三个新的问题。等你意识到时,原本“简单”的仪表盘页面已经需要从头重构了。


这篇文章将向你展示一种更好的做法,至少是我在项目中偏好的架构方式。我们将构建一个三层数据获取架构,它可以从最基本的 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 /> —— 这个调试工具会极大提升你的开发体验。



20250513092135


回到“三层架构”本身。其实它的结构非常简单:



  1. 服务器组件(Server Components) —— 负责初始数据获取

  2. React Query —— 处理客户端的缓存与数据更新

  3. 乐观更新(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 带来的各种性能与维护问题。该模式强调 关注点分离,提升了组件的复用性、可测试性和扩展能力。尽管对小项目可能偏重,但在中大型应用中具备良好的可扩展性和清晰的逻辑组织能力,是构建健壮前端架构的实用指南。


作者:Moment
来源:juejin.cn/post/7503449107542016040
收起阅读 »

初级、中级、高级前端工程师,对于form表单实现的区别

web
在 React 项目中使用 Ant Design(Antd)的 Form 组件能快速构建标准化表单,特别适合中后台系统开发。以下是结合 Antd 的 最佳实践 和 分层实现方案: 一、基础用法:快速搭建标准表单 import { Form, Input, B...
继续阅读 »

在 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 组件,开发者可以快速构建符合企业标准的中后台表单系统。关键在于:



  1. 合理使用内置功能(Form.List、shouldUpdate)

  2. 类型系统深度整合

  3. 性能优化意识

  4. 扩展能力设计(动态表单、可视化配置)


作者:前端开发张小七
来源:juejin.cn/post/7498950758475055119
收起阅读 »

TensorFlow.js 和 Brain.js 全面对比:哪款 JavaScript AI 库更适合你?

web
温馨提示 由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端 AI 开发之旅提供实用参考。 😊 引言:前端 AI 的崛起 在过去的十年里,人工智能(AI)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶...
继续阅读 »

温馨提示


由于篇幅较长,为方便阅读,建议按需选择章节,也可收藏备用,分段消化更高效哦!希望本文能为你的前端 AI 开发之旅提供实用参考。 😊



引言:前端 AI 的崛起


在过去的十年里,人工智能(AI)技术的飞速发展已经深刻改变了各行各业。从智能助手到自动驾驶,从图像识别到自然语言处理,AI 的应用场景几乎无处不在。而对于前端开发者来说,AI 的魅力不仅在于其强大的功能,更在于它已经走进了浏览器,让客户端也能够轻松承担起机器学习的任务。


试想一下,当你开发一个 Web 应用,需要进行图像识别、文本分析、语音识别或其他 AI 任务时,你是否希望直接在浏览器中处理这些数据,而无需依赖远程服务器?如果能在用户的设备上本地运行这些任务,不仅可以大幅提升响应速度,还能减少服务器资源的消耗,为用户提供更流畅的体验。


这正是 TensorFlow.jsBrain.js 两款库所带来的变革。它们使开发者能够在浏览器中轻松实现机器学习任务,甚至支持训练和推理深度学习模型。虽然这两款库在某些功能上有相似之处,但它们的定位和特点却各有侧重。


TensorFlow.js 是由 Google 推出的深度学习框架,它为浏览器端的机器学习提供了强大的支持,能够处理从图像识别到自然语言处理的复杂任务。基于 WebGL 提供加速,TensorFlow.js 可以充分利用硬件性能,实现大规模数据处理和复杂模型推理。


TensorFlow.js 不仅功能强大,还能直接在浏览器中运行复杂的机器学习任务,例如图像识别和处理。如果你想深入了解如何使用 TensorFlow.js 构建智能图像处理应用,可以参考我的另一篇文章:纯前端用 TensorFlow.js 实现智能图像处理应用(一)


相比之下,Brain.js 是一款轻量级神经网络库,专注于简单易用的神经网络模型。它的设计目标是降低机器学习的入门门槛,适合快速原型开发和小型应用场景。尽管 Brain.js 不具备 TensorFlow.js 那样强大的深度学习能力,但它的简洁性和易用性使其成为许多开发者快速实验和实现基础 AI 功能的优选工具。


然而,选择哪款库作为前端 AI 的工具并不简单,这取决于项目的需求、性能要求以及学习成本等多个因素。本文将详细对比两款库的功能、优缺点及适用场景,帮助你根据需求选择最适合的工具。


无论你是 AI 初学者还是有经验的开发者,相信你都能从这篇文章中找到有价值的指导,助力你在浏览器端实现机器学习。准备好了吗?让我们一起探索 TensorFlow.jsBrain.js 的世界,发现它们的不同之处,了解哪一个更适合你的项目。




一、TensorFlow.js - 强大而复杂的深度学习库


TensorFlow


1.1 TensorFlow.js 概述


TensorFlow.js 是由 Google 推出的开源 JavaScript 库,用于在浏览器和 Node.js 环境中执行机器学习任务,包括深度学习模型的推理和训练。它是 TensorFlow 生态的一部分,TensorFlow 是全球最受欢迎的深度学习框架之一,广泛应用于计算机视觉、自然语言处理等领域。


TensorFlow.js 的核心亮点在于其 跨平台支持。你可以在浏览器端运行,也可以在 Node.js 环境下执行,灵活满足不同开发需求。此外,它支持导入已训练好的 TensorFlowKeras 模型,在浏览器或 Node.js 中进行推理,无需重新训练。这使得 AI 的开发更加高效和便捷。


1.2 TensorFlow.js 的功能特点


TensorFlow.js 提供了丰富的功能,覆盖从简单的机器学习到复杂的深度学习任务。以下是它的核心特点:



  1. 浏览器端深度学习推理:通过 WebGL 加速,TensorFlow.js 可以高效地在浏览器中加载和运行深度学习模型,无需依赖服务器,大幅提升用户体验和响应速度。

  2. 训练与推理一体化TensorFlow.js 支持在前端环境直接训练神经网络,这对于动态数据更新和快速迭代非常有用。即使是复杂的深度学习模型,也能通过优化技术确保高效的训练过程。

  3. 支持复杂神经网络架构:包括卷积神经网络(CNN)、循环神经网络(RNN)、以及高级模型如 Transformer,适用于图像、语音、文本等多领域任务。

  4. 模型导入与转换:支持从其他 TensorFlowKeras 环境导入已训练的模型,并在浏览器或 Node.js 中高效运行,降低了开发门槛。

  5. 跨平台支持:无论是前端浏览器还是后端 Node.jsTensorFlow.js 都可以灵活适配,特别适合需要多环境协作的项目。


1.3 TensorFlow.js 的优势与应用场景


优势:


  1. 本地化计算:无需数据传输到服务器,所有计算均在用户设备上完成,提升速度并保障隐私。

  2. 强大的生态支持:依托 TensorFlow 的生态系统,TensorFlow.js 可以轻松访问预训练模型、教程和工具。

  3. 灵活性与高性能:支持低级别 APIWebGL 加速,可根据需求灵活调整模型和计算流程。

  4. 无需后台服务器:在浏览器中即可完成复杂的训练和推理任务,显著简化系统架构。


应用场景:


  1. 图像识别:例如手写数字识别、人脸检测、物体分类等实时图像处理任务。

  2. 自然语言处理:支持情感分析、文本分类、语言翻译等复杂 NLP 任务。

  3. 实时数据分析:适用于 IoT 或其他需要即时数据处理和反馈的应用场景。

  4. 推荐系统:通过用户行为数据构建个性化推荐,例如电商、新闻或社交媒体应用。


1.4 TensorFlow.js 基本用法示例


以下是一个简单示例,展示如何使用 TensorFlow.js 构建并训练神经网络模型。


安装与引入 TensorFlow.js


  1. 通过 CDN 引入:


    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>


  2. 通过 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 - 轻量级且易于使用的神经网络库


Brain


2.1 Brain.js 概述


Brain.js 是一个轻量级的开源 JavaScript 神经网络库,专为开发者提供快速、简单的机器学习工具。它的设计理念是易用性和轻量化,适合那些希望快速构建和训练神经网络的开发者,尤其是机器学习的新手。


与功能丰富的 TensorFlow.js 不同,Brain.js 更注重于直观和简单,能够帮助开发者快速完成从构建到推理的基本机器学习任务。虽然它不支持复杂的深度学习模型,但其易用性和小巧的特性,使其成为小型项目和快速原型开发的理想选择。


2.2 Brain.js 的功能特点


Brain.js 的功能主要集中在简化神经网络的构建与训练上,以下是其核心特点:



  1. 简单易用的 APIBrain.js 提供了直观的接口,开发者无需复杂的机器学习知识,也能轻松上手并实现神经网络任务。

  2. 轻量级:相较于体积较大的 TensorFlow.jsBrain.js 的核心库更为小巧,非常适合嵌入前端应用,且不会显著影响加载速度。

  3. 支持多种网络结构:前馈神经网络(Feedforward Neural Network)、LSTM 网络(Long Short-Term Memory)等。这些模型已足够应对大多数基础的机器学习需求。

  4. 快速训练与推理:通过几行代码即可完成训练与推理任务,适用于快速原型设计和验证。

  5. 同步与异步训练支持Brain.js 同时支持同步和异步的训练过程,开发者可以根据项目需求选择合适的方式。


2.3 Brain.js 的优势与应用场景


优势:



  1. 快速原型开发:开发者可以用最少的代码完成神经网络的构建和训练,特别适合需要快速验证想法的场景。

  2. 轻量级与高效率:库的体积较小,能快速加载,适合资源有限的环境。

  3. 易于集成Brain.js 非常适合嵌入 Web 应用或小型 Node.js 服务,集成简单。

  4. 适合初学者Brain.js 的设计对机器学习新手友好,无需深入了解复杂的深度学习算法即可上手。


应用场景:


  1. 基础分类与预测任务:适合实现简单的分类任务或数值预测,例如时间序列预测、情感分析等。

  2. 教学与实验:对于机器学习教学或学习过程中的快速实验,Brain.js 是一个很好的工具。

  3. 轻量化应用:例如小型交互式 Web 应用中实时处理用户输入。


2.4 Brain.js 基本用法示例


以下示例展示了如何使用 Brain.js 构建并训练一个简单的神经网络模型。


安装与引入


  1. 通过 npm 安装


    npm install brain.js


  2. 通过 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.jsBrain.js 的全面对比


在这一章中,我们将从多个维度对 TensorFlow.jsBrain.js 进行详细对比,帮助开发者根据自己的需求选择合适的工具。对比内容涵盖技术实现差异、学习曲线、适用场景、性能表现以及生态系统和社区支持。


3.1 技术实现差异


TensorFlow.jsBrain.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.jsBrain.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.jsBrain.js 之间做出选择时,开发者需要综合考虑项目需求、技术背景和性能要求。这两款库各有特色:TensorFlow.js 功能强大,适用于复杂任务;Brain.js 简单易用,适合快速开发。以下从选择标准和实际场景出发,帮助开发者找到最合适的工具。


4.1 选择标准


在选择 TensorFlow.jsBrain.js 时,可参考以下几个关键标准:



  1. 功能需求



    • 复杂任务:如果项目涉及深度学习任务(如大规模图像分类、语音识别或自然语言处理),选择 TensorFlow.js 更为合适。它支持复杂的神经网络模型,具备高效的数据处理能力。

    • 基础任务:如果需求相对简单,例如小型神经网络模型、时间序列预测或分类任务,Brain.js 是更轻量的选择。



  2. 开发者经验



    • 有机器学习背景TensorFlow.js 提供高度灵活的 API,但学习曲线较陡。熟悉机器学习的开发者可以充分利用其强大功能。

    • 初学者Brain.js 更适合新手,提供简洁的接口和直观的使用体验。



  3. 性能需求



    • 高性能计算:如果项目需要硬件加速(如 GPU 支持)以处理大规模数据,TensorFlow.jsWebGL 支持是理想选择。

    • 轻量化应用:对于性能要求较低的场景,Brain.js 的轻量级设计足够满足需求。



  4. 项目规模与复杂度



    • 大型项目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.jsBrain.js 的详细对比,可以帮助开发者根据项目需求和个人技能做出最佳选择。以下是两者的优缺点总结及适用场景的建议。


TensorFlow.js 优缺点


优点:



  1. 功能全面:支持复杂的深度学习模型(如 CNNRNNGAN),适用于广泛的机器学习任务,包括图像识别、自然语言处理和语音处理等。

  2. 跨平台支持:可运行于浏览器和 Node.js 环境,灵活部署于多种平台。

  3. 性能卓越:利用 WebGL 实现硬件加速,适合高性能需求,尤其是大规模数据处理。

  4. 强大的生态系统:依托 TensorFlow 生态,拥有丰富的预训练模型、教程和社区支持,为开发者提供充足资源。


缺点:



  1. 学习门槛较高:功能复杂,适合有机器学习基础的开发者,初学者可能需要投入较多时间学习。

  2. 库体积较大:功能的多样性导致库体积偏大,可能影响浏览器加载速度和资源消耗。




Brain.js 优缺点


优点:



  1. 轻量级与易用性:设计简单,API 直观,非常适合快速开发和机器学习初学者。

  2. 小巧体积:库文件体积小,适合嵌入前端应用,对网页加载影响小。

  3. 支持基础模型:支持前馈神经网络和 LSTM,能满足大多数基础机器学习任务。

  4. 快速上手:开发者无需深厚的机器学习知识,能够快速实现简单神经网络应用。


缺点:



  1. 功能较为局限:不支持复杂深度学习模型,难以满足高阶任务需求。

  2. 性能有限:轻量设计决定其在大规模数据处理中的性能不如 TensorFlow.js




适用场景与开发者建议


初学者或简单任务



  • 选择Brain.js

  • 理由:适合刚接触机器学习的开发者,或处理简单分类、时间序列预测等基础任务。其平缓的学习曲线和快速开发特性,帮助初学者快速上手。


经验丰富的开发者或复杂任务



  • 选择TensorFlow.js

  • 理由:适合处理复杂的深度学习任务,如大规模图像识别、自然语言处理或实时视频分析。提供灵活的 API 和强大的计算能力,满足高性能需求。


小型项目与快速开发



  • 选择Brain.js

  • 理由:适合快速构建原型和简单的神经网络任务,易于维护,开发效率高。


大规模应用与高性能需求



  • 选择TensorFlow.js

  • 理由:其强大的加速能力和复杂模型支持,使其成为生产级应用的理想选择,尤其适合需要 GPU 加速的大规模任务。




结语


通过本文的对比,读者可以清晰了解 TensorFlow.jsBrain.js 在功能、性能、学习曲线、适用场景等方面的显著差异。选择最适合的库时,需要综合考虑项目的复杂度、团队的技术背景以及性能需求。


如果你的项目需要处理复杂的深度学习任务,并且需要高性能计算与广泛的社区支持,TensorFlow.js 是不二之选。它功能强大、生态丰富,适合图像识别、自然语言处理等高需求场景。而如果你只是进行小型神经网络实验,或需要快速原型开发,Brain.js 提供了更简洁易用的解决方案,是初学者和小型项目开发者的理想选择。


无论选择哪个库,充分了解它们的优势与限制,将帮助你在项目开发中高效使用这些工具,成功实现你的前端 AI 开发目标。




附录:对比表格


以下对比表格总结了 TensorFlow.jsBrain.js 在关键维度上的差异,帮助读者快速决策:


特性TensorFlow.jsBrain.js
GitHub 星标数量18.6K14.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
收起阅读 »

(紧急修复!)老板急call:pdf阅读器不能用了?

web
客户那边说,发现了我们项目中有一个高危漏洞,需要修复下。我过去一看,好像不是我们的代码,是三方依赖的pdf.js 突然爆了高危,心想,这怎么可能,这东西都发布好多年了,用的好好的也没说高危啊。老板说,这他不管,客户那边的高危项一定要给解决。好吧,那我先去看看这...
继续阅读 »

客户那边说,发现了我们项目中有一个高危漏洞,需要修复下。我过去一看,好像不是我们的代码,是三方依赖的pdf.js 突然爆了高危,心想,这怎么可能,这东西都发布好多年了,用的好好的也没说高危啊。老板说,这他不管,客户那边的高危项一定要给解决。好吧,那我先去看看这个高危是个什么。


一番检索,发现真是 pdf.js 的高危漏洞,而且是今年24年4月26日内部报的,编号是 CVE-2024-4367,并且在今年24年4月30日的 4.2.67 版本上已经修复并发布了。


背景


不管如何,先看这个高危项,它允许攻击者在打开恶意 PDF 文件时立即执行任意 JavaScript 代码。主要是利用了pdf.js 中的字体渲染技术上的特性,当识别到当前浏览器支持 new Function("")并且在加载 pdf 资源时配置了 isEvalSupported 为 true(该值默认为true),此时如果我们在 pdf 资源中输入一些内容,用来控制字体渲染的参数,那么就可以在加载pdf 资源时,执行自己想要的任意的 JavaScript 代码,实现对应用系统的攻击。




解决方案


常规方案有三种



  • 完全杜绝的话可以直接将依赖的 pdf.js-dist 版本升级到 4.2.67+

  • 在使用 pdf.js-dist 的上层代码中将加载 pdf 的参数 isEvalSupported 设置为false

  • 禁用使用 eval 和 Function 构造函数


一般如果对兼容性要求不高的话就可以选择第一种,4.2.67 版本的兼容性legacy版本最低能兼容到以下版本的浏览器


Browser/environmentSupported
Firefox ESR+Yes
Chrome 98+Yes
OperaYes
EdgeYes
Safari 16.4+Mostly
Node.js 18+Mostly

但是如果像一些运行比较久远的至少要兼容到5年以上的设备的话,比如说我司产品,要兼容到 ios10.3(也不知道现在除了我司测试,到底还有谁在用 ios10.3),这种情况下,方案1就完全不可行了,那么就可以考虑使用方案2。


方案3与方案2有相似之处,通过重写 eval 和 Function 来控制内部的 isEvalSupported 的值,也可以避免 pdf文件在被渲染时使用 Function 加载 pdf 内容。


// 重写eval和Function
window.eval = function() {
throw new Error("eval() is disabled");
};

window.Function = function() {
throw new Error("Function constructor is disabled");
};

// pdf.js 中的Function 检测
function isEvalSupported() {
try {
new Function("");
return true;
} catch (e) {
return false;
}
}

const IsEvalSupportedCached = {
get value() {
return shadow(this, "value", isEvalSupported());
}
};

上述的重写会影响全局的 eval 和 Function,若项目中不使用上述功能,可以考虑。若一些内部使用模块使用了以上两个功能,则不建议如此修改。


但是,我们的客户不认,只认依赖版本,我们的 pdf.js-dist 版本低于 4.2.67,这件事在他们的安全报告中,属于完全不能容忍的高危漏洞,一定要解决的,解释也没用,那现在咋办?总不能不用吧?也不能抛弃大部分的低版本客户吧?



那么这个时候,上述三种方案都不能解决问题了,就要考虑其他的方式了。


那么回归我们程序员的本质,只能 fork-modify-push-publish 了。因为只有内部产品使用,也不需要 publish 了,将本来作为第三方的依赖,转成项目内置模块来使用,这个时候想怎么改就能怎么改了。


模块内置后,客户找的安全检测机构也不知道还能不能检测出来,以防万一,还是得把 pdf.js 关于这条安全漏洞的修复给同步到我们的低版本上来。


修复内容


根据官方发布,这条漏洞主要在 pr[18015] 中修复了,那我们把这条 pr 中有关上述漏洞的部分迁移过来即可。不用把这条内容都迁,比如其中对于cmds的重写部分,我们只需要将和isEvalSupported 相关的部分迁移即可,毕竟此漏洞也是由 isEvalSupported 引起的。


主要修复内容:



  • 去除 font_loader.FontFaceObject 中的入参 isEvalSupported 及相关使用该参数的内容

  • 如果使用的版本中isEvalSupported 只用来做字体渲染,可以去除整个 pdf.js 中使用了 isEvalSupported 逻辑的相关内容


通过上述修复方式,客户那边应该也能安心了吧?检索不到低于4.2.67版本的 pdf.js 引用,也不会在解析渲染pdf 资源时,出现外部的 pdf文件对系统造成攻击


关于漏洞


总所周知,pdf.js 里不仅对pdf 文件进行了资源解析,也做了资源的渲染,其中就包含了很多字体字形的绘制,而该漏洞就来源于字体绘制时使用了 new Function(""),导致可以在 pdf 文件中写一些能够被解析的内容,在应用系统中使用 pdf.js 去解析 pdf 文件并在绘制时执行任意的 JavaScript 内容,造成对系统的攻击。


// pdf.js font_loader 字体绘制中能够执行任意js内容的部分
if (this.isEvalSupported && FeatureTest.isEvalSupported) {
const jsBuf = [];
for (const current of cmds) {
const args = current.args !== undefined ? current.args.join(",") : "";
jsBuf.push("c.", current.cmd, "(", args, ");\n");
}
// eslint-disable-next-line no-new-func
console.log(jsBuf.join(""));
return (this.compiledGlyphs[character] = new Function(
"c",
"size",
jsBuf.join("")
));
}

具体的绘制可以在在 PDF.js 中执行任意 JavaScript 中查看,以下为在 pdf 文件中输入任意代码的示例,


通过首先关闭c.transform(...)函数,并利用结尾括号来触发 alert:


/FontMatrix [1 2 3 4 5 (0); alert('foobar')]

将上述内容输入到 pdf 文件中,然后在火狐浏览器(未更新最新版本的 pdf 预览插件版本)中加载该 pdf 文件时的效果如下:



也可以使用 旧版本的 pdf.js 开源的 viewer 打开该文件,有一样的效果。


附录:


可用于攻击的 pdf 文件地址:codeanlabs.com/wp-content/…


CVE-2024-4367 漏洞详细攻击介绍:codeanlabs.com/blog/resear…


CVE-2024-4367 漏洞详情及相关修改 pr:github.com/mozilla/pdf…


pdf.js 相关文档推荐


前端接入 pdfjs-dist 渲染 pdf 文件踩坑


PDF.js 与 WebComponent:打造轻量级 PDF 预览器


作者:九酒
来源:juejin.cn/post/7408168213362507827
收起阅读 »

纯前端也能实现 OCR?

web
前言 前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract...
继续阅读 »

前言


前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract.js


Tesseract.js


Tesseract.js 是一个基于 Google Tesseract OCR 引擎的 JavaScript 库,利用 WebAssembly 技术将的 OCR 引擎带到了浏览器中。它完全运行在客户端,无需依赖服务器,适合处理中小型图片的文字识别。


主要特点



  • 多语言支持:支持多种语言文字识别,包括中文、英文、日文等。

  • 跨平台:支持浏览器和 Node.js 环境,灵活应用于不同场景。

  • 开箱即用:无需额外依赖后端服务,直接在前端实现 OCR 功能。

  • 自定义训练数据:支持加载自定义训练数据,提升特定场景下的识别准确率。


安装


通过 npm 安装


npm install tesseract.js

通过 CDN 引入


<script src="https://unpkg.com/tesseract.js@latest/dist/tesseract.min.js"></script>

基本使用


以下示例展示了如何使用 Tesseract.js 从图片中提取文字:


import Tesseract from 'tesseract.js';

Tesseract.recognize(
'image.png', // 图片路径
'chi_sim', // 识别语言(简体中文)
{
logger: info => console.log(info), // 实时输出进度日志
}
).then(({ data: { text } }) => {
console.log('识别结果:', text);
});

示例图片



运行结果



可以看到,虽然识别结果不完全准确,但整体准确率较高,能够满足大部分需求。


更多用法


1. 多语言识别


Tesseract.js 支持多语言识别,可以通过字符串或数组指定语言代码:


// 通过字符串的方式指定多语言
Tesseract.recognize('image.png', 'eng+chi_sim').then(({ data: { text } }) => {
console.log('识别结果:', text);
});

// 通过数组的方式指定多语言
Tesseract.recognize('image.png', ['eng','chi_sim']).then(({ data: { text } }) => {
console.log('识别结果:', text);
});

eng+chi_sim 表示同时识别英文和简体中文。Tesseract.js 内部会将字符串通过 split 方法分割成数组:


const currentLangs = typeof langs === 'string' ? langs.split('+') : langs;

2. 处理进度日志


可以通过 logger 回调函数查看任务进度:


Tesseract.recognize('image.png', 'eng', {
logger: info => console.log(info.status, info.progress),
});

输出示例:



3. 自定义训练数据


如果需要识别特殊字符,可以加载自定义训练数据:


const worker = await createWorker('语言文件名', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false, // 是否对来自远程的训练数据进行 gzip 压缩
langPath: '/path/to/lang-data' // 自定义训练数据路径
});


[!warning] 注意:



  1. 第一个参数为加载自定义训练数据的文件名,不带后缀。

  2. 加载自定义训练数据的文件后缀名必须为 .traineddata

  3. 如果文件名不是 .traineddata.gzip,则需要设置 gzipfalse



举例


const worker = await createWorker('my-data', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false,
langPath: 'http://localhost:5173/lang',
});

加载效果



4. 通过前端上传图片


通常,图片是通过前端让用户上传后进行解析的。以下是一个简单的 Vue 3 示例:


<script setup>
import { createWorker } from 'tesseract.js';

async function handleUpload(evt) {
const files = evt.target.files;
const worker = await createWorker("chi_sim");
for (let i = 0; i < files.length; i++) {
const ret = await worker.recognize(files[i]);
console.log(ret.data.text);
}
}
</script>

<template>
<input type="file" @change="handleUpload" />
</template>

完整示例


下面提供一个简单的 OCR 示例,展示了如何在前端实现图片上传、文字识别以及图像处理。


代码


<!--
* @Author: zi.yang
* @Date: 2024-12-10 09:15:22
* @LastEditors: zi.yang
* @LastEditTime: 2025-01-14 08:06:25
* @Description: 使用 tesseract.js 实现 OCR
* @FilePath: /vue-app/src/components/HelloWorld.vue
-->

<script setup lang="ts">
import { ref } from 'vue';
import { createWorker, OEM } from 'tesseract.js';

const uploadFileName = ref<string>("");
const imgText = ref<string>("");

const imgInput = ref<string>("");
const imgOriginal = ref<string>("");
const imgGrey = ref<string>("");
const imgBinary = ref<string>("");

async function handleUpload(evt: any) {
const file = evt.target.files?.[0];
if (!file) return;
uploadFileName.value = file.name;
imgInput.value = URL.createObjectURL(file);
const worker = await createWorker("chi_sim", OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
});
const ret = await worker.recognize(file, { rotateAuto: true }, { imageColor: true, imageGrey: true, imageBinary: true });
imgText.value = ret.data.text || '';
imgOriginal.value = ret.data.imageColor || '';
imgGrey.value = ret.data.imageGrey || '';
imgBinary.value = ret.data.imageBinary || '';
}

// 占位符 svg
const svgIcon = encodeURIComponent('<svg t="1736901745913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4323" width="140" height="140"><path d="M804.9 243.4c8.1 0 17.1 10.5 17.1 24.5v390.9c0 14-9.1 24.5-17.3 24.5H219.3c-8 0-17.3-10.7-17.3-24.5V267.9c0-14 9.1-24.5 17.3-24.5h585.6m0-80H219.3c-53.5 0-97.3 47-97.3 104.5v390.9c0 57.3 43.8 104.5 97.3 104.5h585.4c53.5 0 97.3-47 97.3-104.5V267.9c0-57.5-43.7-104.5-97.1-104.5z" fill="#5E9EFC" p-id="4324"></path><path d="M678.9 294.5c28 0 50.6 22.7 50.6 50.6 0 28-22.7 50.6-50.6 50.6s-50.6-22.7-50.6-50.6c0-28 22.7-50.6 50.6-50.6z m-376 317.6l101.4-215.7c6-12.8 24.2-12.8 30.2 0l101.4 215.7c5.2 11-2.8 23.8-15.1 23.8H318c-12.2 0-20.3-12.7-15.1-23.8z" fill="#5E9EFC" p-id="4325"></path><path d="M492.4 617L573 445.7c4.8-10.1 19.2-10.1 24 0L677.6 617c4.1 8.8-2.3 18.9-12 18.9H504.4c-9.7 0-16.1-10.1-12-18.9z" fill="#5E9EFC" opacity=".5" p-id="4326"></path></svg>');
const placeholder = 'data:image/svg+xml,' + svgIcon;
</script>

<template>
<div class="custom-file-upload">
<label for="file-upload" class="custom-label">选择文件</label>
<span id="file-name" class="file-name">{{ uploadFileName || '未选择文件' }}</span>
<input id="file-upload" type="file" @change="handleUpload" />
</div>

<div class="row">
<div class="column">
<p>输入图像</p>
<img alt="原图" :src="imgInput || placeholder">
</div>
<div class="column">
<p>旋转,原色</p>
<img alt="原色" :src="imgOriginal || placeholder">
</div>
<div class="column">
<p>旋转,灰度化</p>
<img alt="灰度化" :src="imgGrey || placeholder">
</div>
<div class="column">
<p>旋转,二值化</p>
<img alt="二进制" :src="imgBinary || placeholder">
</div>
</div>

<div class="result">
<h2>识别结果</h2>
<p>{{ imgText || '暂无结果' }}</p>
</div>
</template>

<style scoped>
/* 隐藏原生文件上传按钮 */
input[type="file"] {
display: none;
}

/* 自定义样式 */
.custom-file-upload {
display: inline-block;
cursor: pointer;
margin-bottom: 30px;
}

.custom-label {
padding: 10px 20px;
color: #fff;
background-color: #007bff;
border-radius: 5px;
display: inline-block;
font-size: 14px;
cursor: pointer;
}

.custom-label:hover {
background-color: #0056b3;
}

.file-name {
margin-left: 10px;
font-size: 14px;
color: #555;
}

.row {
display: flex;
width: 100%;
justify-content: space-around;
}

.column {
width: 24%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
text-align: center;
min-height: 100px;
}

.column > p {
margin: 0 0 10px 0;
padding: 5px;
border-bottom: 1px solid #ccc;
font-weight: 600;
}

.column > img {
width: 100%;
}

.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}

.result > h2 {
margin: 0;
}

.result > p {
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
font-size: 16px;
line-height: 1.5;
color: #333;
margin: 10px 0;
}
</style>

实现效果



资源加载失败


Tesseract.js 在运行时需要动态加载三个关键文件:Web Workerwasm训练数据。由于默认使用的是 jsDelivr CDN,国内用户可能会遇到网络加载问题。为了解决这个问题,可以通过指定 unpkg CDN 来加速资源加载:


const worker = await createWorker('chi_sim', OEM.DEFAULT, {
langPath: 'https://unpkg.com/@tesseract.js-data/chi_sim/4.0.0_best_int',
workerPath: 'https://unpkg.com/tesseract.js/dist/worker.min.js',
corePath: 'https://unpkg.com/tesseract.js-core/tesseract-core-simd-lstm.wasm.js',
});

如果需要离线使用,可以将这些资源下载到本地,并将路径指向本地文件即可。


结语


Tesseract.js 是目前前端领域较为成熟的 OCR 库,适合在无需后端支持的场景下快速实现文字识别功能。通过合理的图片预处理和优化,可以满足大部分中小型应用的需求。


相关链接



作者:子洋
来源:juejin.cn/post/7459791088791797786
收起阅读 »

我开源了一个基于 Tiptap 实现一个功能丰富的协同编辑器 🚀🚀🚀

web
一个基于 Tiptap 和 Next.js 构建的现代化协同文档编辑器,集成了丰富的编辑能力与多人实时协作功能,支持插件扩展、主题切换与持久化存储。适合团队写作、教育笔记、在线文档平台等场景。 项目地址 预览地址 无论你是想学习或者想参与开发,你都可以添加...
继续阅读 »

一个基于 TiptapNext.js 构建的现代化协同文档编辑器,集成了丰富的编辑能力与多人实时协作功能,支持插件扩展、主题切换与持久化存储。适合团队写作、教育笔记、在线文档平台等场景。



无论你是想学习或者想参与开发,你都可以添加我微信 yunmz777,我拉你进交流群中进行学习交流,我们还有很多其他不同的开源项目。



近期开始准备出一个 前端工程化实战 类的课程,如果你对前端技术迷茫,那么学习前端工程化是最好的一个进阶方案,以下是相关的实战内容大纲:



20250519222445


如果你感兴趣想参与的,可以添加我微信进行更详细的了解。


🚀 功能特性



  • 📄 富文本编辑:标题、列表、表格、代码块、数学公式、图片、拖拽等

  • 👥 实时协作:使用 Yjs + @hocuspocus/provider 实现高效协同

  • 🧩 插件丰富:基于 Tiptap Pro 多种增强功能(如表情、详情组件等)

  • 🧰 完善工具链:支持 Prettier、ESLint、Husky、Vitest 等开发工具


📦 技术栈


前端技术栈


技术说明
Next.js构建基础框架,支持 SSR / SSG
Tiptap富文本编辑器,基于 ProseMirror
Yjs协同编辑核心,CRDT 数据结构
@hocuspocusYjs 的服务端与客户端 Provider
React 19UI 框架,支持 Suspense 等新特性
Tailwind CSS原子化 CSS,集成动画、表单样式等
Socket.io协同通信通道
Prettier/ESLint代码风格统一
Vitest/Playwright单元测试与端到端测试支持

20250519183256


后端技术栈


分类技术 / 工具说明
应用框架NestJS现代化 Node.js 框架,支持模块化、依赖注入、装饰器和类型安全等特性
HTTP 服务Fastify高性能 Web 服务引擎,替代 Express,默认集成于 NestJS 中
协同编辑服务@hocuspocus/server, yjs提供文档协同编辑的 WebSocket 服务与 CRDT 算法实现
数据库 ORMPrisma类型安全的数据库访问工具,自动生成 Schema、支持迁移与种子数据
数据验证class-validator, class-transformer请求数据验证与自动转换,配合 DTO 使用
用户鉴权@nestjs/passport, passport, JWT, GitHub支持本地登录、JWT 认证与 GitHub OAuth 登录
缓存与状态ioredis用于缓存数据、实现限流、协同会话管理或 Pub/Sub 消息推送
对象存储minio私有化部署的 S3 兼容存储服务,支持图片与附件上传
图像处理sharp图像压缩、格式转换、缩略图等操作
日志系统winston, winston-daily-rotate-file支持多种格式、日志分级、自动归档的日志方案
服务监控@nestjs/terminus, prom-client提供 /health 健康检查和 /metrics Prometheus 指标暴露接口
监控平台Prometheus, Grafana采集与可视化服务运行指标(已内置 Docker 部署配置)
接口文档@nestjs/swagger基于代码注解自动生成 Swagger UI 文档
安全中间件@fastify/helmet, @fastify/rate-limit添加 HTTP 安全头部、限制请求频率、防止暴力攻击等安全保护
文件上传@fastify/multipart, @webundsoehne/nest-fastify-file-upload支持文件流式上传,集成 Fastify 与 NestJS 的多文件上传处理

20250519183049


🚀 快速开始


1. 克隆仓库


git clone https://github.com/xun082/DocFlow.git
cd DocFlow

安装依赖


建议使用 pnpm:


pnpm install

启动本地开发环境


pnpm dev

如何部署


确保已安装以下环境:



  • Docker

  • 推荐:Linux/macOS 或启用 WSL 的 Windows 环境


1️⃣ 构建镜像


docker build -t doc-flow .

2️⃣ 启动容器


docker run -p 6001:6001 doc-flow

启动完成之后访问地址:


http://localhost:6001

🔧 常用脚本


脚本命令作用说明
pnpm dev启动开发服务器
pnpm build构建生产环境代码
pnpm start启动生产环境服务(端口 6001)
pnpm lint自动修复所有 ESLint 报错
pnpm format使用 Prettier 格式化代码
pnpm type-check运行 TypeScript 类型检查
pnpm test启动测试(如配置)

🧰 开发规范



  • 使用 Prettier 和 ESLint 保证代码风格统一

  • 配置了 Husky + lint-staged 进行 Git 提交前检查

  • 使用 Commitizen + cz-git 管理提交信息格式(支持语义化发布)


初始化 Git 提交规范:


pnpm commit

📌 未来规划(Roadmap)


项目目前已具备基础协作编辑能力,未来将持续完善并拓展更多功能,进一步提升产品的实用性与专业性:


✅ 近期目标




  • 完善现有功能体验



    • 优化协同冲突解决策略

    • 更细粒度的权限管理(只读 / 可评论 / 可编辑)

    • 增强拖拽体验与文档结构导航(大纲视图)




  • 增强文档组件系统



    • 重构基础组件体系:标题、表格、代码块等更智能、模块化

    • 增加工具栏、快捷键提示和 Markdown 快速输入支持




  • 丰富文档类型与节点支持



    • 支持更多 自定义 Tiptap 节点,如:



      • 引用评论块(Comment Block)

      • 自定义警告框 / 提示框(Tip/Warning)

      • UML/流程图嵌入(如支持 Mermaid)

      • 数据展示组件(如 TableChart、Kanban)






🚀 中期目标




  • 引入音视频实时会议能力



    • 集成 LiveKitDaily 实现嵌入式音视频会议

    • 支持多人语音 / 视频通话,结合文档协同,提升远程会议效率

    • 集成会议内共享笔记区、AI 摘要、会议录制等功能




  • 集成 AI 能力



    • 智能语法纠错、改写建议

    • 语义搜索与问答(支持上下文理解)

    • AI 总结 / 摘要生成




  • 多平台同步支持



    • PWA 支持,适配移动端和桌面离线编辑

    • 跨设备自动同步与版本恢复




🧠 长期方向




  • 插件生态系统建设



    • 引入用户可安装的第三方插件体系

    • 提供插件开发文档与市场入口




  • 文档协作平台化



    • 支持文档团队空间、多人组织结构

    • 文档看板与团队活动看板集成




  • 权限与审计系统



    • 支持操作日志记录、文档编辑历史审查

    • 审批流、编辑建议、协同讨论区等功能




License


本项目采用 MIT 开源协议发布,但包含部分 Tiptap Pro 模板代码除外


Tiptap Pro 模板版权归 Tiptap GmbH 所有,并根据 Tiptap Pro 授权协议进行授权。

详见:tiptap.dev/pro/license


如需使用本项目中涉及 Tiptap Pro 的部分,必须拥有有效的 Tiptap Pro 订阅授权。


📬 联系方式


有更多的问题或者想参与开源,可以添加我微信 yunmz777,我们这还有很多开源项目:


20250519222610


作者:Moment
来源:juejin.cn/post/7505969919029542949
收起阅读 »

手把手教你实现一个自动倒啤酒的效果

web
前言 继上次实现一个汽车运货的效果后,这次我就带大家来实现一个自动倒酒的效果,纯CSS实现,十分简单,没有花里胡哨的技巧。话不多说,咱们直接进入主题。 效果预览 最终实现的相关效果如下。 HTML部分 首先看到HTML部分。相关代码如下。 <div ...
继续阅读 »

前言


继上次实现一个汽车运货的效果后,这次我就带大家来实现一个自动倒酒的效果,纯CSS实现,十分简单,没有花里胡哨的技巧。话不多说,咱们直接进入主题。


效果预览


最终实现的相关效果如下。


HTML部分


首先看到HTML部分。相关代码如下。


 <div class="container">
<div class="keg">
<span class="handle"></span>
<span class="pipe"></span>
</div>
<div class="glass">
<span class="beer"></span>
</div>
</div>

这里定义了一个啤酒桶(keg)和玻璃杯(glass)的容器结构,通常用于模拟倒啤酒的动画场景。


.container 是最外层容器,用于定位整个啤酒桶和玻璃杯的组合。 .keg(啤酒桶) 包含两个子元素分别是 .handle(啤酒桶的金属把手)以及 .pipe(出酒管道)。 .glass(玻璃杯) 包含  .beer 元素,表示杯中的啤酒液体,通常通过CSS动画模拟啤酒被倒入的效果。


CSS部分


由于这里的效果涉及了很多动画效果,并且作为该效果的主要功能是倒酒,所以我们在这里主要介绍动画相关的CSS部分,即是如何实现倒酒。相关代码如下。


这里是类名为flow的动画部分,相关代码如下。


@keyframes flow {
0%, 15% {
top: 40px;
height: 0;
}

20% {
height: 115px;
}

40% {
height: 75px;
}

55% {
top: 40px;
height: 50px;
}

60%, 100% {
top: 80px;
height: 0;
}
}

这个动画的整体效果是液体从无到有爆发式流出 → 流量逐渐减少 → 最后滴落消失,通过 height 变化模拟液体体积变化,通过 top 调整模拟液体位置移动(如滴落时的垂直位移)。最后再不断循环。


初始状态(0%, 15%) 液体不可见(高度为0),准备开始流动。液体开始流出(20%) 液体高度突然增加(从0到115px),模拟液体从管道中快速涌出。液体减少(40%) 液体高度降低(从115px到75px),模拟流量减小。液体即将流尽(55%) 液体顶部位置回弹(可能模拟最后几滴液体下落),高度进一步减小。液体完全消失(60%, 100%) 液体高度归零,同时顶部位置下移(top: 80px),模拟液体完全流尽或滴落。


最后就是handle,slide的动画部分,相关代码如下。


@keyframes handle {
10%, 60% {
transform: rotate(0deg);
}

20%, 50% {
transform: rotate(-90deg);
}
}
@keyframes slide {
0% {
left: 0;
filter: opacity(0);
}

20%, 80% {
left: 300px;
filter: opacity(1);
}

100% {
left: 600px;
filter: opacity(0);
}
}

这里定义了两个关键帧动画:handle(把手旋转)  和 slide(水平滑动淡入淡出)


把手旋转动画模拟把手(如啤酒桶开关)的来回扳动效果。在 0%-10% 保持初始状态(0deg)。在20% 快速逆时针旋转到-90deg(如打开阀门)。在50% 仍保持-90deg(持续打开状态)。在60% :回到0deg(关闭阀门)。


水平滑动动画 实现元素从左侧滑入、暂停、再滑出并淡出的效果。在 0% 元素从左侧(left: 0)透明状态开始。在 20%-80% 滑动到中间(left: 300px)并完全显示。在 100% 继续滑到右侧(left: 600px)并淡出。


最后就是fillup,fillup-foam,wave的动画部分,相关代码如下。


@keyframes fillup {
0%, 20% {
height: 0px;
border-width: 0px;
}

40% {
height: 40px;
}

80%, 100% {
height: 80px;
border-width: 5px;
}
}
@keyframes fillup-foam {
0%, 20% {
top: 0;
height: 0;
}

60%, 100% {
top: -15px;
height: 15px;
}
}

@keyframes wave {
from {
transform: skewY(-3deg);
}

to {
transform: skewY(3deg);
}
}

这里定义了三个关键帧动画,用于模拟液体(如啤酒)倒入容器时的动态效果,包括液体上升、泡沫生成和液体表面波动。


液体填充动画 模拟液体从空杯到满杯的填充过程。在 0%-20% 容器为空(高度为0)。在 40% 液体快速上升至半满(40px)。在 80%-100% 液体完全填满(80px),同时显示容器边框(如玻璃杯厚度)。


泡沫生成动画 模拟液体倒满时产生的泡沫层。在 0%-20% 无泡沫(高度为0)。在 60%-100% 泡沫在液体顶部形成并略微溢出(top: -15px)。通过 top 负值实现泡沫“溢出”杯口的视觉效果。


液体波动动画 模拟液体表面的轻微波动(如倒入后的晃动)。通过 skewY 实现Y轴倾斜变换,产生波浪效果。通常需配合 animation-direction: alternate 让动画来回播放。


总结


以上就是整个效果的实现过程了,纯 CSS 实现,代码简单易懂。另外,感兴趣的小伙伴们还可以在现有基础上发散思维,比如增加点其他效果,或者更改颜色等等。关于该效果如果大家有更好的想法欢迎在评论区分享,互相学习。最后,完整代码在码上掘金里可以查看,如果有什么问题大家在评论区里讨论~


作者:一条会coding的Shark
来源:juejin.cn/post/7502329326098366473
收起阅读 »

我在团队内部提倡禁用 css margin

web
新的文章已经写完了,从技术角度详详细细的介绍了我的理由,朋友们在阅读完本文之后,如果还有兴趣可继续深入阅读 juejin.cn/post/747896… 一一一分割线一一一 目前社区也有不少人提倡禁用 margin,大概原因有什么奇怪的边距融合、责任区域划分不...
继续阅读 »

新的文章已经写完了,从技术角度详详细细的介绍了我的理由,朋友们在阅读完本文之后,如果还有兴趣可继续深入阅读
juejin.cn/post/747896…


一一一分割线一一一


目前社区也有不少人提倡禁用 margin,大概原因有什么奇怪的边距融合、责任区域划分不明确等等,我今天从另一个角度来说明为什么不要使用 margin


我们现在处于协同化时代,基本都是靠 figma、motiff 这类在线设计工具看设计稿的。这类工具有写特点



  • 没有 margin 概念

  • 只有自动布局和约束布局两种方式

  • 有研发模式


自动布局等同于 flex 布局,支持设置主轴方向,主轴辅轴对其方式,间距(gap),边距(padding)等等
image.png
下面是我随手画的一个例子,在研发模式下,鼠标 hover 到容器上面,会出现蓝色和粉色区域。蓝色就代表 padding,粉色就代表 gap
image.png


约束就是绝对定位,这个很简单,不详细阐述
image.png


所以,由于工具的天然限制,设计师在画稿的时候,不会像写代码一些,条条大路通罗马。比如我想让两个 div 相距 100px,css 起码得有 10 种方式。所以我们作为前端开发,拿到设计稿的时候可以放心的相信设计师的打组结构,设计稿一个框,你就写一个 div。因为他们不会有天马行空的骚操作,两个设计师是有很大概率画出结构一样的设计稿的。


实战
我在 figma 画了一个移动端界面


image.png
然后切换到研发模式,从外向内开始选中图层查看细节


image.png
可以看到结构是一套四,竖向 flex 布局,间距是 29px padding 是 0


// frame 7
<div class='flex flex-col gap-29px'>
// frame 8
<div></div>
// frame 9
<div></div>
// frame 10
<div></div>
// frame 10
<div></div>
</div>

然后直接看最后一个图层,前面的简单就不看了
image.png
可以一看看出结构是 flex 横向布局,padding 13px 34px,justify-content: space-between
然后可以继续无脑的写代码了


// frame 7
<div class='flex flex-col gap-29px'>
// frame 8
<div></div>
// frame 9
<div></div>
// frame 10
<div></div>
// frame 10
<div class='flex px-13px py-34px justify-between'>
// star 3
<div></div>
// star 4
<div></div>
// star 5
<div></div>
// star 6
<div></div>
</div>
</div>

然后增加一个回到顶部的 float button,约束为右、下。
image.png
hover 到 button 上


image.png
发现出现了两条线,指向右和下,这就代表这是一个相对于父元素的右下角的绝对定位图层。只需要无脑写代码即可


// frame 7
<div class='relative flex flex-col gap-29px'>
.....
<div class='absolute right-xxx bottom-xxx w-10 h-10'></div>
</div>

总结


在使用 figma、motiff 这类的工具的情况下,



  1. 前端程序员可以无脑的根据设计稿分组来写自己的 html,绝大部分情况他们应该是一对一的。

  2. 应该跟随工具,只使用 flex 布局,绝对定位布局

  3. 绝大部分情况不应该使用 margin


确实存在一些情况使用 margin 会更方便,我也真实遇到了一些 case。如果你们有想聊的 case 可以发到评论区


作者:阿古达木
来源:juejin.cn/post/7478182398409965620
收起阅读 »

微信小程序包体积治理

web
背景 微信考虑到小程序的体验和性能问题限制主包不能超过2M。哈啰微信小程序也随着业务线在主包中由简到复杂,体积越来越大,前期业务野蛮增长阶段npm库缺乏统一管理,第三方组件库本身工程复杂等问题导致包体积长期处于2M临界卡点,目前存在以下痛点: 阻塞各业务正常...
继续阅读 »

背景


微信考虑到小程序的体验和性能问题限制主包不能超过2M。哈啰微信小程序也随着业务线在主包中由简到复杂,体积越来越大,前期业务野蛮增长阶段npm库缺乏统一管理,第三方组件库本身工程复杂等问题导致包体积长期处于2M临界卡点,目前存在以下痛点:



  • 阻塞各业务正常微信小程序端需求排期。

  • 迭代需求需要人肉搜索包体积的增长点,推动增长业务线去优化对应的包体积,治标不治本。

  • 缺乏微信端包体积统一管理平台来限制各业务包体积增长。

  • 微信包体积太大导致加载时间长、体验差。


所以主要从包体积优化和长期控制包体积增长两个方面让微信包体积达到平衡状态,长期运行。


包体积优化


微信包体积优化是个老生常谈的话题,只要是公司业务体积达到一定的量级都会不可避免的碰到主包体积超出和体验问题,关于怎么解决官方和网上也给出了比较多的解决方案。知其然知其所以然,那我们就从小程序的原理层面去看解决方案。主要也分为常规的优化方案和结合业务优化技术方案。


常规优化方案


按照微信小程序官网介绍,我们把小程序的性能优化分为启动性能优化和运行时性能优化:



  • 启动性能 :小程序的启动过程以「用户打开小程序」为起点,到小程序「首页渲染完成」为止。小程序「首页渲染完成」的标志是首个页面 Page.onReady 事件触发。

  • 运行时性能:小程序的运行时性能直接决定了用户在使用小程序功能时的体验。如果运行时性能出现问题,很容易出现页面滚动卡顿、响应延迟等问题,影响用户使用。如果内存占用过高,还会出现黑屏、闪退等问题。


1.启动性能优化

在进行启动性能优化之前,先介绍下小程序启动流程,小程序的启动流程主要包括以下几个环节:


image.png


1.1 资源准备

a. 小程序相关信息准备:微信客户端需要从微信后台获取小程序的头像、昵称、版本、配置、权限等基本信息,这些信息会在本地缓存,并通过一定的机制进行更新。


b. 环境预加载(受到场景、设备资源和操作系统调度的影响,并不能保证每次启动一定命中)


为了尽可能的降低运行环境准备对启动耗时的影响,微信客户端会根据用户的使用场景和设备资源的使用情况,依照一定策略在小程序启动前对运行环境进行部分地预加载,以降低启动耗时。


image.png
c. 代码包准备

从微信后台获取代码包的地址,从CDN下载小程序代码包,并对代码包进行校验。

为了提高下载耗时,微信本身就做了一些优化:



  • 代码包压缩

  • 增量更新

  • 更高效的网络协议:下载代码包优先使用 QUIC 和 HTTP/2

  • 预先建立连接:在下载发生前,提前和 CDN 建立连接,降低下载过程中 DNS 请求和连接建立的耗时。

  • 代码包复用:对每个代码包都会计算 MD5 签名。即使发生了版本更新,如果代码包的 MD5 没有发生变化,则不需要重新进行下载。


1.2 小程序代码注入

小程序启动时需要从代码包内读取小程序的配置和代码,并注入到 JavaScript 引擎中,同时WXSS 和 WXML 会编译成 JavaScript 代码注入到视图层,视图层和逻辑层的小程序代码注入是并行进行的。


微信客户端会使用 V8 引擎的 Code Caching 技术对代码编译结果进行缓存,降低非首次注入时的编译耗时(Code Caching:V8会把编译和解析的结果缓存下来,等到下次遇到相同的文件时,直接使用缓存数据)


1.3 首屏渲染\


image.png


视图层和逻辑层都是从start并行进行初始化操作,视图层初始化完毕后会发送notify给逻辑层,自身进入等待状态,逻辑层收到信号后会结合自身初始化状态(第一种没初始化完,继续初始化。第二种初始化完进入等待状态)发送初始数据Data到视图层,结合初始数据和视图层得到的页面结构和样式信息,小程序框架会进行小程序首页的渲染,展示小程序首屏,并触发首页的 Page.onReady 事件。


1.4 优化方案

a. 控制包体积:降低代码包大小是最直接的手段,代码包大小直接影响了下载耗时,影响用户启动小程序时的体验。



  • 分包:使用 分包加载 是优化小程序启动耗时效果最明显的手段。及时清理无用代码和资源。

  • 独立分包。

  • 分包预下载:在使用「分包加载」后,虽然能够显著提升小程序的启动速度,但是当用户在使用小程序过程中跳转到分包内页面时,需要等待分包下载完成后才能进入页面,造成页面切换的延迟,影响小程序的使用体验

  • 分包异步化:「分包异步化」将小程序的分包从页面粒度细化到组件甚至文件粒度。


b. 代码注入优化:



  • 按需引入:在小程序启动时,启动页面依赖的所有代码包(主包、分包、插件包、扩展库等)的所有 JS 代码会全部合并注入,包括其他未访问的页面以及未用到自定义组件,同时所有页面和自定义组件的 JS 代码会被立刻执行。这造成很多没有使用的代码在小程序运行环境中注入执行,影响注入耗时和内存占用。

  • 用时注入:在开启「按需注入」特性的前提下,「用时注入」可以指定一部分自定义组件不在小程序启动时注入,而是在真正渲染的时候才进行注入。


c. 首屏渲染优化:



  • 启用【初始渲染缓存】:启用初始渲染缓存,可以使视图层不需要等待逻辑层初始化完毕,而直接提前将页面初始 data 的渲染结果展示给用户,这可以使得页面对用户可见的时间大大提前。

  • 数据预拉取:预拉取能够在小程序冷启动的时候通过微信后台提前向第三方服务器拉取业务数据,当代码包加载完时可以更快地渲染页面,减少用户等待时间,从而提升小程序的打开速度 。

  • 周期性更新:周期性更新能够在用户未打开小程序的情况下,也能从服务器提前拉取数据,当用户打开小程序时可以更快地渲染页面,减少用户等待时间,增强在弱网条件下的可用性。

  • 骨架屏:如果首页内容是通过接口异步获取的,用户不一定立即看到完整的界面,需要等待接口返回后调用setData进行页面更新,才能看到真实内容,避免过长时间白屏可以选择骨架屏来提高用户体验。


2.运行时性能优化

2.1 优化方案:

a. 合理使用setData:小程序的逻辑层和视图层是两个独立的运行环境,通讯通过Native层实现。具体的实现原理和bridge实现一致,ios利用WKWebView提供的messageHandlers,安卓是往webview的window对象注入一个原生方法,所以数据传输的耗时和数据量的大小成正比。


b. 页面切换优化:页面切换的性能影响用户操作的连贯性和流畅度,是小程序运行时性能的一个重要组成部分。


请求前置:小程序不同于H5,在跳转本身就需要消耗比较多的时间,特别是在安卓机上,所以我们可以在页面跳转的同时进行数据并行请求。
image.png


c. 控制预加载下个页面的时机(仅安卓):

小程序页面加载完成后,会预加载下一个页面。默认情况下,小程序框架会在当前页面 onReady 触发 200ms 后触发预加载。


在安卓上,小程序渲染层所有页面的 WebView 共享同一个线程。很多情况下,小程序的初始数据只包括了页面的大致框架,并不是完整的内容。页面主体部分需要依靠 setData 进行更新。因此,预加载下一个页面可能会阻塞当前页面的渲染,造成 setData 和用户交互出现延迟,影响用户看到页面完整内容的时机。


image.png


我们本次拉齐两轮、数科、普惠,分别进行部分页面分包,下掉0流量页面及其依赖的npm包,把仅有单个业务线引用的npm从主小程序移植到分包下从而不占用主包体积,删除无用文件等操作才从2M体积减少到1.88M,这个收益对于反复优化过的主小程序而言已经算是不错的收益,但是很难满足未来各业务线对小程序主包体积的迭代诉求,所以我们还需要更优的解决方案来减少更多的包体积和限制各业务线在现有体积上进行置换而不是无限扩张。


关于这两个问题我们就在结合业务优化方案和长期控制包体积机制中探讨。


结合业务优化方案


1.第三方组件库异步分包

微信小程序为考虑体验问题主包被限制到了2M,但随着小程序业务线接入越来越多,npm库缺乏统一管理,第三方组件库本身工程比较复杂等问题导致主包超过1M+都被npm库所占用掉,留给业务的空间不足1M,所以可以从vendor.js中进行部分拆分优化,在不占用主包体积下主包也能够使用这些第三方库。


这样操作的意义在于可以把部分第三方npm库抽离到分包中,主包内只剩核心业务和不能拆的npm库。


实现原理:小程序的分包异步化就是来实现这种功能的,按照微信官方文档提供可以使用require来异步引入包含第三方npm的分包。


image.png


但是我们的小程序是使用taro,通过webpack进行编译的,静态产物不支持CommonJS模块的写法,所以require在编译的时候会进行报错,解决方法有两种:



  • 自定义webpack插件,将require关键字替换为customRequireKey(自定义key值,在解析的时候替换成require就可以)。


image.png



  • webpack提供的__non_webpack_require__代替require,不会被webpack解析。


注意点1:如果把第三方npm库改成异步引用后,对于之前通过import同步引用的代码需要进行改造,不然可能会出现在包引入前提前调用包内部方法的问题,对于这个问题可以创建缓存队列解决。


注意点2:分包因为网络波动等原因会加载失败,但是概率极低,可以使用重试机制解决。


2.封面方案

封面方案相比于第三方组件异步分包方案更好理解,就是把业务全部抽离到分包中,主包中只保留各业务线所依赖的基础库和公共文件,在小程序启动的时候做个启动界面,页面一旦加载就立即跳转到真正承载业务的页面中, 而这个页面被放在分包中。


这么做的好处在于主包中的2M体积只用来放基础库和公共文件,包体积始终控制在1M左右,对小程序性能优化和体验上都有很大的提升。而其他业务都放在业务的主分包中进行管理。


image.png


长期控制包体积机制


主包体积优化后如果缺乏标准的控制方法,在未来还是会随着各业务迭代增加不停的增加直到超出2M。所以一套标准的管理机制也是至关重要的。


小程序包体积治理主要从两个方面:



  • 业务线管理机制后台

  • 发布系统管理机制


业务线管理机制后台


业务线size管理机制后台主要集临时资源申请和图标展示于一体,以解决业务线临时size压力。可以通过后台系统进行临时size申请,提出申请后说明申请原因、资源需要时长、size大小,到达审批人时可酌情考虑,审批通过\不通过后都会钉钉通知申请。在管理平台也能看到当前业务线的永久size、临时size、临时size到期时间、申请理由和各业务每迭代包体积大小等信息。


image.png


a. 申请临时资源流程:用户根据自己的诉求进入后台选择对应业务线点击新增按钮去申请临时资源、申请临时资源时需在申请弹窗中明确以下几点内容:



  • 申请资源大小:最大申请资源为当前包体积剩余的最大值

  • 使用时间:最多为2个迭代就要把临时资源退回、否则限制发布流程

  • 申请理由:在理由中需要明确填写申请资源后带来的业务价值、由平台的产品侧和研发侧共同衡量价值。

  • prd地址:链接地址。


b. 申请临时资源最长路径:最多为2个迭代就要把申请的临时资源进行退回、否则在发布时限制发布。


c. 临时申请最大包体积:申请最大资源为当前包体积剩余的最大值


d. 包体积到期通知:提前一个迭代时间钉钉通知对应的申请人和leader包体积到期时间进行优化,申请资源到期后后台系统会自动把申请的资源状态改为已到期,并减少对应申请的资源大小,如果未归还对应体积大小,在发布流程阶段会做体积大小卡口,限制发布。


发布系统管理机制


发布系统管理机制主要流程是developer在AppHelloBikeWXSS项目上每次通过feature分支merge到release分支的时候都会触发gitlab的钩子函数,然后触发jenkins的job进行编译、计算现在各业务线在主包中所占的体积,在通过包体积管理后台申请的体积进行比对,如果超出会钉钉通知到开发者并且在发布系统限制发布,如果没超出正常发布。


image.png
(本文作者:董恒磊)


作者:哈啰技术小编
来源:juejin.cn/post/7381657886801805312
收起阅读 »

前端页面怎么限制用户打开浏览器控制台?

web
说在前面 作为一名开发,相信大家对于浏览器控制台都是不陌生的,平时页面一出问题第一反应总是先打开控制台看看报错信息,而且还可以在控制台里插入自己的脚本信息来修改页面逻辑,那么你有没有想过 怎么限制用户打开控制台 呢? 禁用右键菜单 🔨 添加图片注释,不超...
继续阅读 »

说在前面



作为一名开发,相信大家对于浏览器控制台都是不陌生的,平时页面一出问题第一反应总是先打开控制台看看报错信息,而且还可以在控制台里插入自己的脚本信息来修改页面逻辑,那么你有没有想过 怎么限制用户打开控制台 呢?



禁用右键菜单 🔨



添加图片注释,不超过 140 字(可选)


在页面上点击鼠标右键我们可以看到有个 检查 选项,通过这个菜单可以直接打开控制台,我们可以直接在这个页面上禁用右键菜单。


document.addEventListener("contextmenu", e => e.preventDefault());  

加上这段代码后用户在页面上点击右键就不会有反应了。


拦截快捷键 🛑


除了右键菜单栏,还有最经典的 F12 ,通过 F12 快捷键也可以快速打开控制台,所以我们也可以将这个快捷键给拦截掉


document.addEventListener("keydown", e => {  
if (e.keyCode === 123) {
e.preventDefault();
}
});

那么除了 F12 你知道还有什么快捷键可以打开控制台吗?



  • Ctrl+Shift+C

  • Ctrl+Shift+I


上面这两个快捷键也可以打开控制台,还有一个快捷键 Ctrl+U 可以打开源码页面,这里我们也可以一起把它给拦截掉。


document.addEventListener("keydown", e => {  
if (e.keyCode === 123 || // F12
(e.ctrlKey && e.shiftKey && e.keyCode === 67) || // Ctrl+Shift+C
(e.ctrlKey && e.shiftKey && e.keyCode === 73) || // Ctrl+Shift+I
(e.ctrlKey && e.keyCode === 85)) { // Ctrl+U
e.preventDefault();
}
});

加上这段代码后用户在页面上按下这些快捷键就不会有反应了。


检测窗口变化🔷


加上前面的拦截之后,其实我们还是有办法打开控制台,可以通过浏览器设置来打开控制台,这里的入口我们并无法监听拦截到



添加图片注释,不超过 140 字(可选)


let lastWidth = window.innerWidth;
let lastHeight = window.innerHeight;

window.addEventListener("resize", () => {
const widthDiff = Math.abs(window.innerWidth - lastWidth);
const heightDiff = Math.abs(window.innerHeight - lastHeight);

// 如果窗口尺寸变化但不是全屏切换,可能是控制台打开
if ((widthDiff > 50 || heightDiff > 50) && !isFullScreen()) {
//跳转到空白页面
window.location.href = "about:blank";
alert("检测到异常窗口变化,请关闭开发者工具");
}
lastWidth = window.innerWidth;
lastHeight = window.innerHeight;
});

function isFullScreen() {
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
}

通常默认是会在页面内靠边打开控制台,所以可以通过监听页面大小变化来简单判断是否打开控制台,监听到打开后直接跳转到空白页面。



添加图片注释,不超过 140 字(可选)


但是还有这么两种情况



  • 全屏切换时的尺寸变化可能被误判

  • 独立打开控制台页面时无法监听到


无限Debugger⚡


我们还可以通过 Function("debugger") 来动态生成断点(动态生成是为了防断点禁用),通过无限循环生成断点,让页面一直处于断点状态。


(() => {  
function block() {
setInterval(() => {
(function(){return false;})["constructor"]("debugger")["call"]();
}, 50);
}
try { block(); } catch (err) {}
})();


添加图片注释,不超过 140 字(可选)



虽然我们可以通过一些技术手段,给用户打开控制台设置一些障碍,但对于经验老到的用户而言,绕过这些限制并非难事。依赖前端技术拦截控制台访问是一种典型的“防君子不防小人”策略,不能想着靠这些手段来保障自己网站的安全。



公众号


关注公众号『 前端也能这么有趣 』,获取更多有趣内容。


发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~


说在后面



🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。



作者:JYeontu
来源:juejin.cn/post/7508362269586063360
收起阅读 »

前端遇到高并发如何解决重复请求

web
在前端开发中遇到高并发场景时,若不加控制容易出现重复请求,这可能导致接口压力增加、数据异常、用户体验变差等问题。以下是前端防止/解决重复请求的常见方法,按不同场景归类总结: 🌟 一、常见重复请求场景 用户频繁点击按钮:多次触发相同请求(例如提交表单、下载操...
继续阅读 »

在前端开发中遇到高并发场景时,若不加控制容易出现重复请求,这可能导致接口压力增加、数据异常、用户体验变差等问题。以下是前端防止/解决重复请求的常见方法,按不同场景归类总结:




🌟 一、常见重复请求场景



  1. 用户频繁点击按钮:多次触发相同请求(例如提交表单、下载操作)。

  2. 路由短时间内多次跳转或刷新:导致重复加载数据。

  3. 多次调用 debounce/throttle 未正确控制函数执行时机

  4. 轮询或 WebSocket 消息导致并发访问同一接口




🚀 二、常用解决方案


✅ 1. 禁用按钮防止多次点击


const [loading, setLoading] = useState(false);

const handleClick = async () => {
if (loading) return;
setLoading(true);
try {
await fetchData();
} finally {
setLoading(false);
}
};
<Button loading={loading} onClick={handleClick}>提交</Button>

✅ 2. 使用请求缓存 + Map 记录请求状态


原理:在请求发出前先检查是否已有相同请求在进行。


const requestCache = new Map();

const requestWithDeduplication = (url: string, options: any = {}) => {
if (requestCache.has(url)) {
return requestCache.get(url); // 复用已有请求
}

const req = fetch(url, options).finally(() => {
requestCache.delete(url); // 请求结束后清除缓存
});

requestCache.set(url, req);
return req;
};


适合统一封装 fetchaxios 请求,避免相同参数的并发请求。





✅ 3. 使用 Axios 的 CancelToken 取消上一次请求


let controller: AbortController | null = null;

const request = async (url: string) => {
if (controller) {
controller.abort(); // 取消上一个请求
}
controller = new AbortController();

try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} catch (e) {
if (e.name === 'AbortError') {
console.log('Request canceled');
}
}
};


适合搜索联想、快速切换 tab 等需要 只保留最后一次请求 的场景。





✅ 4. 使用 debounce/throttle 防抖节流


import { debounce } from 'lodash';

const fetchData = debounce((params) => {
// 实际请求
}, 300);

<input onChange={(e) => fetchData(e.target.value)} />


控制高频输入类请求频率,减少并发请求量。





✅ 5. 后端幂等 + 前端唯一请求 ID(可选高级方案)



  • 给每次请求生成唯一 ID(如 UUID),发送给后端。

  • 后端对相同 ID 请求只处理一次。

  • 前端避免再做复杂状态判断,适合交易、支付类场景。




🧠 小结对照表


场景推荐方案
按钮多次点击禁用按钮 / loading 状态
相同请求并发请求缓存 Map / Axios CancelToken
输入频繁调用接口debounce 防抖
只保留最后一个请求AbortController / CancelToken
表单提交 /交易请求幂等请求唯一 ID + 后端幂等处理



如果你告诉我你遇到的具体是哪个页面或场景(例如点击下载、搜索联想、多 tab 切换等),我可以给出更加定制化的解决方案。


作者:光影少年
来源:juejin.cn/post/7507560729609830434
收起阅读 »

她说:JSON 没错,但就是 parse 不过?我懂了!

web
技术纯享版:《不规范 JSON 怎么办?三种修复思路+代码实现》 开篇:夜色渐浓,佳人亦在 那天晚上,办公室的灯已经灭了大半,只剩几个工位发出轻轻的蓝光。中央空调早就熄了,但显示器的热度依然在屏幕前形成一圈圈淡淡的光晕。 我坐在靠窗的位置,刚把代码提交推送完...
继续阅读 »

技术纯享版:《不规范 JSON 怎么办?三种修复思路+代码实现》



开篇:夜色渐浓,佳人亦在


那天晚上,办公室的灯已经灭了大半,只剩几个工位发出轻轻的蓝光。中央空调早就熄了,但显示器的热度依然在屏幕前形成一圈圈淡淡的光晕。


我坐在靠窗的位置,刚把代码提交推送完,正打算收键盘走人。


这时,小语走过来,端着还冒着热气的速溶咖啡——她果然又是那个留下来最晚的人之一。


“诶~”她蹲在我旁边的桌子边上,语气带着一丝挫败,“你这边有没有遇到 JSON 字符串明明格式看着没错,却死活 JSON.parse 不过的情况?”


一个普通的错误,却不是普通的崩溃


原来她在调试一个用户日志上传模块,前端接收到的日志数据是从后端来的 JSON 字符串。


问题出在一个看似再平常不过的解析操作上——


const logData = JSON.parse(incomingString);

可是控制台总是报错:Unexpected token。数据一眼看去也没问题,{'name': 'Tom', 'age': 30} —— 结构清晰,属性齐全,但偏偏就是“坏掉了”。


她抿了一口咖啡,苦笑,“我知道是引号的问题,可这种数据是从破旧的系统里吐出来的,量还特别大,我不可能一个个手动改。”


风起 · JSON.parse 不是万灵药


我们一起回顾了她的实现方式。她用的是最基础的 JSON.parse(),这是我们在项目里默认的处理方式——简单、直接、快速。


但这个方法对 JSON 格式的要求极其严格:



  • 只能使用双引号 "

  • 属性名必须加引号

  • 不容忍任何额外字符或注释


一旦出现诸如单引号、缺少逗号、多余空格这些“微小过失”,就直接抛错了。


小语叹气,“很多时候这些 JSON 是设备端拼出来的,不规范,又没有错误提示,我根本不知道该怎么修。”


我翻了翻之前的代码,从夹缝中找出来一张破旧的黄皮纸,我们俩一起瞅了上去,看到上面写着


function tryParseJSON(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {
// 尝试简单修复:去除可能的多余字符
const cleaned = jsonString.replace(/[^\x20-\x7E]/g, '').trim();
try {
return JSON.parse(cleaned);
} catch (e2) {
console.error("无法解析JSON:", e2);
return null;
}
}
}

下面备注了一行小字:此法在一些更轻量的场景里,做一些“简陋修复“,对于简单的问题有时能奏效,但对于更复杂的错误,比如混合了单引号和双引号的情况,只能再实现另一个方法可以做更针对性的修复方法


function fixQuotes(jsonString) {
// 将单引号替换为双引号(简单情况)
return jsonString.replace(/'/g, '"');
}

小语感叹一声:“没有更好的了吗?”


解决篇 · 来自大佬的一句话


恰好这时,阿杰从会议室出来,耳机还挂在脖子上。


他听了一耳朵后随口说了句:“你们试过 jsonrepair 吗?那玩意能把坏 JSON 修回来,就像修车。”


“json... repair?”小语一脸困惑。


我忽然想起,之前有个日志监控服务也碰到类似的问题,当时就是用了这个库一把梭。


我打开编辑器,快速翻出来了这一段:


npm install jsonrepair

const { jsonrepair } = require('jsonrepair');

const damaged = "{name: 'John', age: 30}";
const fixed = jsonrepair(damaged); // => {"name":"John","age":30}
const obj = JSON.parse(fixed);

小语凑过来看了一眼,眼睛一亮:“它真的把引号补好了?”


我点头。这个工具是为了解决类似“非标准 JSON”问题的,它会尽可能地补全缺失引号、逗号,甚至处理 Unicode 异常字符。


当然,也不是所有情况都适用。


比如碰到乱码或者非法嵌套结构,jsonrepair 有时也会无能为力。这时可以退一步——用更宽松的解析器,比如 JSON5


const JSON5 = require('json5');
const result = JSON5.parse("{name: 'John', age: 30}"); // 也能解析

我看着认真学习的小语,语重心长的讲道:它不是修复,而是扩展 JSON 标准,让一些非标准写法也能解析(JSON5 能容忍的内容包括:单引号、尾逗号、注释、未加引号的属性名、十六进制、科学计数法等数字格式)


接着我们还讨论了更复杂的修复方式,比如用正则处理批量日志,甚至用 AST 工具逐步构建 JSON 树。但那是更远的故事了。


面对当前的问题,我们准备搞一套组合拳:


function parseJson(jsonString) {
// 第一步:尝试标准JSON解析
try {
return JSON.parse(jsonString);
} catch (e) {
console.log("标准JSON解析失败,尝试修复...");

// 第二步:尝试使用jsonrepair修复
try {
const { jsonrepair } = require('jsonrepair');
const fixedJson = jsonrepair(jsonString);
return JSON.parse(fixedJson);
} catch (e2) {
console.log("修复失败,尝试使用JSON5解析...");

// 第三步:尝试使用JSON5解析
try {
const JSON5 = require('json5');
return JSON5.parse(jsonString);
} catch (e3) {
// 最后:如果所有方法都失败,返回错误信息
console.error("所有解析方法都失败了:", e3);
throw new Error("无法解析JSON数据");
}
}
}
}

结局


一段时间后,小语在前端监控日志里贴了段截图:原本一天上千条的 parse error 错误,几乎消失了。


她补了一句:“终于不用再一个个点开调日志了。”


我回头看她的工位,屏幕亮着,浏览器里是一个模拟器页面,console 正在缓缓输出内容。


她突然抬起头看着我,问道:“AST是什么?听说也能实现json修复?”


作者:洛小豆
来源:juejin.cn/post/7506754146894168118
收起阅读 »

Flutter - 聊天键盘与面板丝滑切换的强势升级 🍻

web
欢迎关注微信公众号:FSA全栈行动 👋 BiliBili: http://www.bilibili.com/video/BV1yT… 一、概述 距离 chat_bottom_container 首个可用版本 (0.0.2) 的发布已经过去了 1 个多月,在...
继续阅读 »

欢迎关注微信公众号:FSA全栈行动 👋




BiliBili: http://www.bilibili.com/video/BV1yT…


一、概述


距离 chat_bottom_container 首个可用版本 (0.0.2) 的发布已经过去了 1 个多月,在这期间根据大家的使用反馈,我们也做了一些优化调整,今天就来盘点一下到底做了哪些优化,新增了什么功能,以及一些常见操作。


请注意




开源不易,如果你也觉得这个库好用,请不吝给个 Star 👍 ,并多多支持!


github.com/LinXunFeng/…



二、使用


调整键盘高度监听管理逻辑


0.1.0 版本前,只考虑了页面栈这种常规情况,当键盘高度变化时只处理栈顶的监听。


但其实还有一种常见打破该规则的场景,就是悬浮聊天页,它会一直在页面上,可能为了能快速从悬浮小球展开聊天页面,收起时只是做了隐藏,而不会销毁页面,在这种情况下,它依旧在监听管理里的栈顶,所以在收起后,上一个聊天页的键盘高度监听就会失效。


这个在 0.1.0 版本中得到修复,内部会倒序遍历调用所有的监听回调。


不过你不用担心这一改动会导致其它聊天页面出现多余的视图刷新,因为在键盘高度监听回调里会先判断输入框是否有焦点,若无则直接返回了。


兼容外接键盘


当连接外接键盘时,软键盘会消失,高度会降为 0,这里可以用 iOS 模拟器结合 Toggle Software Keyboard (快捷键: cmd + k) 来模拟连接与断开外接键盘的效果。



隐藏面板


有小伙伴提出,不知道如何程序式的隐藏面板,其实很简单,就两步



  1. 让输入框失去焦点

  2. 更新内部状态为 ChatBottomPanelType.none


hidePanel() {
// 0.2.0 前
inputFocusNode.unfocus();
if (ChatBottomPanelType.none == controller.currentPanelType) return;
controller.updatePanelType(ChatBottomPanelType.none);

// 0.2.0 后,可以这么写
controller.updatePanelType(
ChatBottomPanelType.none,
forceHandleFocus: ChatBottomHandleFocus.unfocus,
);
}

自定义底部安全区高度


在默认情况下,chat_bottom_container 在收起模式 (.none) 下会自动帮你添加底部安全区高度,但在一些场景下你可能不希望如此。比如:



  • 安卓的底部安全区的高度,很多小伙伴都是简单粗暴的设置个高度了事

  • App 首页有底部 BottomNavigationBar,不需要安全区高度


在此,你可以通过将 safeAreaBottom 参数来自定义这个高度,如下设置为 0


return ChatBottomPanelContainer<PanelType>(
...
safeAreaBottom: 0,
...
);

调整键盘面板高度


如示例中位于首页的聊天页面



在键盘弹出时,如下图所示


实际期望

很明显,我们希望键盘容器高度能够减去外层底部固定的 BottomNavigationBar 高度。


ChatBottomPanelContainer 提供了 changeKeyboardPanelHeight 回调,在回调中可以拿到当前的键盘高度,经过计算后,将合适的键盘容器高度返回即可。


return ChatBottomPanelContainer<PanelType>(
...
changeKeyboardPanelHeight: (keyboardHeight) {
final renderObj = bottomNavigationBarKey.currentContext?.findRenderObject();
if (renderObj is! RenderBox) return keyboardHeight;
return keyboardHeight - renderObj.size.height;
},
...
);

缓存键盘高度


先来看未做键盘高度缓存处理之前,会发生什么?



上图一共进入了三次聊天页



  • 第一次是先点击键盘,再切到表情面板,体验起来还是挺不错的。

  • 为了避免一闪而过,没有注意到,所以第二次和第三次的操作是一样的,先唤起表情面板,再切到键盘,可以看到在切到键盘时会抖动。


这是因为每次进入聊天页,键盘的高度为初始值 0,在 0.2.0 版本中对此进行了优化,加入了键盘高度缓存逻辑,从而尽量避免该抖动问题的出现。



❗️ 但需要注意的是,假如你卸载重装 App,该缓存会丢失,即你还是有可能会看到最多一次的抖动。



除此之外,你还可以使用这个缓存的键盘高度来实现表情面板与键盘高度保持一致的效果,这样在切换的时候体验上会更好些。😉


Widget _buildEmojiPickerPanel() {
// 如果键盘高度还没有缓存过,则使用默认高度 300
double height = 300;
final keyboardHeight = controller.keyboardHeight;
if (keyboardHeight != 0) {
height = keyboardHeight;
}

return Container(
padding: EdgeInsets.zero,
height: height,
color: Colors.blue[50],
child: const Center(
child: Text('Emoji Panel'),
),
);
}

效果如下



支持表情面板与输入框焦点共存


这也是提升用户体验的重要一点,效果见上图。


先按如下设置你的输入框


bool readOnly = false;
TextEditingController textEditingController = TextEditingController();

...
TextField(
controller: textEditingController,
focusNode: inputFocusNode,
// 为 true 时不显示键盘,默认为 false
readOnly: readOnly,
// 获取焦点后显示光标,设置为 true 才不受 readOnly 的影响
showCursor: true,
),
...

接下来就是切换表情面板的操作


switchToEmojiPanel() {
readOnly = true;
// 这里你可以只刷新输入框
setState(() {});

// 等待下一帧
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
controller.updatePanelType(
// 内部切至 other 状态
ChatBottomPanelType.other,
// 关联外部的面板类型为表情面板
data: PanelType.emoji,
// 输入框获取焦点
forceHandleFocus: ChatBottomHandleFocus.requestFocus,
);
});
}

updatePanelType 方法中,如果是切至 .other 状态,是会帮你执行失去焦点操作的,所以这里提供了一个 forceHandleFocus 参数,如果你对方法内部对焦点的处理不满意,你可以使用它来强制指定焦点的处理方式。


三、最后


好了,上述便是该库的更新内容, 惯例附上 GitHub 地址: github.com/LinXunFeng/… ,如果接入上有什么问题,可以在链接中查看 demo 演示代码。


开源不易,如果你也觉得这个库好用,请不吝给个 Star 👍 ,并多多支持!


本篇到此结束,感谢大家的支持,我们下次再见! 👋



如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~



作者:LinXunFeng
来源:juejin.cn/post/7399045497002328102
收起阅读 »

一个js库带你看懂AI+前端的发展方向

web
前言 随着技术的发展,人工智能正逐渐渗透到我们生活的方方面面,从前端开发到后端服务,从数据分析到用户体验设计。特别是在前端领域,AI 的应用正成为一个不可忽视的趋势。本文将探讨 AI 在前端领域的应用,并重点介绍一个在浏览器端即可运行的神经网络库——Brain...
继续阅读 »

前言


随着技术的发展,人工智能正逐渐渗透到我们生活的方方面面,从前端开发到后端服务,从数据分析到用户体验设计。特别是在前端领域,AI 的应用正成为一个不可忽视的趋势。本文将探讨 AI 在前端领域的应用,并重点介绍一个在浏览器端即可运行的神经网络库——Brain.js。


Brain.js:浏览器端的神经网络库


Brain.js 是一个专为前端开发者设计的 JavaScript 库,它允许开发者在浏览器或 Node.js 环境中轻松创建和训练神经网络。以下是 Brain.js 的几个核心能力:



  1. 投喂数据训练



  • Brain.js 支持以 JSON 数组的形式投喂数据,这使得准备训练数据变得非常简单。例如,可以准备一个包含输入和期望输出的数据集,用于训练神经网络。


const trainingData = [
{ input: [0, 0], output: { zero: 1 } },
{ input: [0, 1], output: { one: 1 } },
{ input: [1, 0], output: { one: 1 } },
{ input: [1, 1], output: { zero: 1 } }
];


  1. 实例化神经网络



  • Brain.js 提供了多种类型的神经网络,包括前馈神经网络(Feedforward Neural Networks)和循环神经网络(Recurrent Neural Networks)。对于文本处理和序列数据,推荐使用 brain.recurrent.LSTM(),这是一种长短期记忆网络,特别适合处理时间序列数据。



  1. 训练模型



  • 训练神经网络非常简单,只需调用 train 方法并传入训练数据即可。Brain.js 会自动调整网络参数,使模型逐步学会从输入数据中提取特征并作出准确的预测。



  1. 推理能力



  • 训练完成后,可以使用 run 方法对新的输入数据进行推理。例如,在 NLP 场景中,可以使用训练好的模型对用户输入的文本进行情感分析或分类。



  1. 结果分类



  • Brain.js 支持多分类任务,可以将输入数据归类到多个预定义的类别中。这对于内容推荐、垃圾邮件过滤等应用场景非常有用。


开始使用 Brain.js:


要开始使用 Brain.js,首先需要安装它。如果你是在 Node.js 环境下工作,可以通过 npm 安装:


npm install brain.js

如果你在浏览器中使用,可以直接通过 CDN 引入:


<script src="https://cdn.jsdelivr.net/npm/brain.js"></script>

然后可以按照官方文档提供的示例代码来构建你的第一个神经网络模型。


示例1:


// 创建一个神经网络
const network = new brain.NeuralNetwork();

// 用 4 个输入对象训练网络
network.train([
{ input: [0, 0], output: { zero: 1 } },
{ input: [0, 1], output: { one: 1 } },
{ input: [1, 0], output: { one: 1 } },
{ input: [1, 1], output: { zero: 1 } }
]);

// [1, 0] 的预期输出是什么?
const result = network.run([1, 0]);

// 显示 "zero" 和 "one" 的概率
console.log(result["one"] + " " + result["zero"]);


  • 使用 new brain.NeuralNetwork() 创建一个神经网络。

  • 使用 network.train([examples]) 训练网络。

  • examples 表示 4 个输入值及其对应的输出值。

  • 使用 network.run([1, 0]) 询问 "[1, 0] 的可能输出是什么?"


网络的输出是:



  • one: 93%(接近 1)

  • zero: 6%(接近 0)


使用 CSS,颜色可以通过 RGB 设置:


示例2:

颜色RGB
黑色RGB(0,0,0)
黄色RGB(255,255,0)
红色RGB(255,0,0)
白色RGB(255,255,255)
浅灰色RGB(192,192,192)
深灰色RGB(65,65,65)

下面的代码展示了如何预测颜色的深浅:


// 创建一个神经网络
const net = new brain.NeuralNetwork();

// 用 4 个输入对象训练网络
net.train([
// 白色 RGB(255, 255, 255)
{ input: [255 / 255, 255 / 255, 255 / 255], output: { light: 1 } },
// 浅灰色 (192, 192, 192)
{ input: [192 / 255, 192 / 255, 192 / 255], output: { light: 1 } },
// 深灰色 (64, 64, 64)
{ input: [65 / 255, 65 / 255, 65 / 255], output: { dark: 1 } },
// 黑色 (0, 0, 0)
{ input: [0, 0, 0], output: { dark: 1 } }
]);

// 深蓝色 (0, 0, 128) 的预期输出是什么?
let result = net.run([0, 0, 128 / 255]);

// 显示 "dark" 和 "light" 的概率
console.log(result["dark"] + " " + result["light"]);


  • 使用 new brain.NeuralNetwork() 创建一个神经网络。

  • 使用 network.train([examples]) 训练网络。

  • examples 表示 4 个输入值及其对应的输出值。

  • 使用 network.run([0, 0, 128 / 255]) 询问 "深蓝色的可能输出是什么?"


网络的输出是:



  • Dark: 95%

  • Light: 4%


示例3:

下面这个例子演示如何使用 Brain.js 创建并训练一个基本的神经网络,该网络学习从摄氏度转换为华氏度:


const brain = require('brain.js');

// 创建一个 LSTM 神经网络实例
const net = new brain.recurrent.LSTM();

// 准备训练数据
const trainingData = [
{ input: '0', output: '32' }, // 0°C -> 32°F
{ input: '100', output: '212' } // 100°C -> 212°F
];

// 训练神经网络
net.train(trainingData, {
iterations: 20000, // 训练迭代次数
log: (stats) => console.log(`Training progress: ${stats.iterations}/${stats.error}`) // 训练日志
});

// 使用训练好的模型进行推理
const output = net.run('50'); // 预测 50°C 对应的华氏温度
console.log(output); // 输出结果接近 "122"

其他用于创建神经网络的js库


TensorFlow.js、Synaptic.js、ConvNetJS、Keras.js、Deeplearn.js (现更名为 TensorFlow.js)、 ML.js等。
这些js库作为在浏览器端即可运行的神经网络库,为前端开发者提供了强大的工具,使得我们能够在不深入数学和机器学习理论的前提下,快速实现和应用机器学习功能。无论是简单的分类任务、预测建模,还是更复杂的自然语言处理和图像识别,它们都能帮助你轻松应对。


结语


你发现了吗,通过brain.js,你也可以轻松地将机器学习功能集成到你的项目中。未来,随着模型的小型化、边缘计算的发展以及多模态融合的推进,AI + 前端将更加普及和成熟。


R-C.png


点个赞再走吧~


作者:PW
来源:juejin.cn/post/7438876948762066980
收起阅读 »

请放弃使用JPEG、PNG、GIF格式的图片!

web
随着互联网的发展,图片作为最直观的内容展示方式逐渐在系统中占用越来越多的版面,但是随之而来的就是系统性能的大幅度下滑。传统的JPEG、PNG、GIF各有优点,也各有弊端,“大一统”的图片格式被需要,于是WebP诞生了。 需求 WebP格式文件产生的原因主要是源...
继续阅读 »

随着互联网的发展,图片作为最直观的内容展示方式逐渐在系统中占用越来越多的版面,但是随之而来的就是系统性能的大幅度下滑。传统的JPEG、PNG、GIF各有优点,也各有弊端,“大一统”的图片格式被需要,于是WebP诞生了。


需求


WebP格式文件产生的原因主要是源于对网络图像传输效率的需求以及现有图像格式在某些方面的局限性


在现代互联网网页中图片和视频占据了很大比例。为了提供更吸引人的用户体验,网站需要加载大量的高质量图像


image.png


同时智能手机和平板电脑的普及推动了移动互联网的快速发展。在移动设备上,网络速度通常比桌面端慢,且用户的流量是有限的。


而JPEG、PNG和GIF等传统图像格式各有其优点,但也存在不足之处。


例如,JPEG虽然非常适合照片,但仅支持有损压缩且不支持透明度;PNG支持透明度但文件大小通常较大;GIF支持动画但色彩范围有限,且文件体积相对较大。


产生


WebP是一种由Google开发的图像文件格式,旨在提供更高效的图片压缩,适用于网络图像传输和展示。



  1. 高压缩效率:WebP采用了先进的压缩算法,可以提供比JPEG更高的压缩率而不会明显损失图像质量。这意味着使用WebP格式可以在不牺牲视觉体验的情况下显著减少图片文件的大小,从而加快网页加载速度。

  2. 支持透明度:与JPEG不同,WebP支持alpha通道(即透明度),这使得它在需要背景透明效果的应用场景中成为PNG的一个有力替代者,同时还能以更低的文件大小实现这一功能。

  3. 动画支持:除了静态图像外,WebP还支持动画,作为一种更加有效的替代GIF的方案。相比GIF,WebP能够以更小的文件尺寸提供更高品质的动画效果和更多的色彩支持。

  4. 广泛兼容性:虽然WebP最初由Google推出,但它逐渐获得了广泛的浏览器和其他平台的支持,包括Chrome、Firefox、Edge、Safari等主流浏览器,以及各种操作系统和图像处理软件。


image.png


局限



  1. 浏览器兼容性:虽然大多数现代浏览器已经支持WebP格式,但仍有少数旧版浏览器可能不完全支持或根本不支持这种格式。在转换的同时也需要准备适当的回退方案(如提供JPEG或PNG版本的图像)。

  2. 性能问题:尽管WebP通常能提供更好的压缩率和质量比,但在某些情况下,转换过程可能会增加服务器负载,尤其是在需要实时生成WebP图像的情况下。

  3. 特定需求和偏好:一些网站可能基于设计、品牌或其他技术要求而选择特定的图像格式。例如,对于需要极高保真度的专业摄影展示,可能仍然倾向于使用TIFF或高质量JPEG格式。


使用


在线格式转换



ced075b010f14508be723fb7830d3287_2.png


程序格式转换


Python:可以使用Pillow库(PIL的一个分支)结合webp的支持来进行转换。


// 安装 pip install Pillow

from PIL import Image
im = Image.open("input.png")
im.save("output.webp", "WEBP")

也可以使用Node.js来转换。


这里使用egg.js作为服务端框架


前端


<template>
<div class="wrap">
<a-upload
v-model:file-list="fileList"
name="file"
action="/api/uploadImg"
:accept="['.jpeg','.png','.jpg','.gif']"
@change="handleChange"
>
<a-button>
上传文件
</a-button>
</a-upload>

<div class="diff-wrap">
<div class="old-img">
<img style="max-width: 400px;max-height: 500px;" :src="oldImg" alt=""/>
</div>
<div class="new-img">
<img style="max-width: 400px;max-height: 500px;" :src="newImg" alt=""/>
</div>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue';
const oldImg = ref('');
const newImg = ref('');

const handleChange = info => {
const file = info.file;

// 使用 FileReader 进行本地文件预览(无论上传是否成功)
const reader = new FileReader();
reader.onload = () => {
oldImg.value = reader.result; // 将本地文件的 Base64 赋值给 oldImg
};
reader.readAsDataURL(file.originFileObj); // 读取原始文件对象

// 原有上传完成逻辑可保留用于处理服务器返回结果
if (file.status === 'done' && file.response) {
console.log(file)
newImg.value = file.response.url; // 如果上传成功,使用服务器返回的 URL
}
};

const fileList = ref([]);
</script>

<style scoped>
.diff-wrap {
width: 800px;
margin: 20px auto;
border: 1px solid #ddd;
display: flex;
}

.old-img {
flex: 1;
height: 500px;
border-right: 1px solid #ddd;
}

.new-img {
flex: 1;
height: 500px;
}
</style>

服务端


使用 Node.js 的图像处理库 sharp 进行格式转换,安装 sharp。


npm install sharp

示例代码


const { Service } = require('egg');
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');

class HomeService extends Service {
async index() {
return 'hello world';
}

async uploadImg() {
const { ctx } = this;

try {
// 1. 获取上传的文件流
const stream = await ctx.getFileStream();

// 2. 检查是否为支持的图片格式(可选)
const allowedMimes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ];
if (!allowedMimes.includes(stream.mime)) {
throw new Error('Unsupported image format');
}

// 3. 定义路径
const tempInputPath = path.join(this.config.baseDir, 'app/public', `temp_${Date.now()}.tmp`);
const outputFilename = `converted_${Date.now()}.webp`;
const outputFilePath = path.join(this.config.baseDir, 'app/public', outputFilename);

// 4. 写入临时原始文件
const writeStream = fs.createWriteStream(tempInputPath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('end', resolve);
stream.on('error', reject);
});

// 5. 使用 sharp 转换为 webp
await sharp(tempInputPath)
.webp({ quality: 80 }) // 可设置压缩质量
.toFile(outputFilePath);

// 6. 清理临时文件
fs.unlinkSync(tempInputPath);

// 7. 返回 WebP 图片地址
return {
url: `/public/${outputFilename}`,
filename: outputFilename,
};
} catch (err) {
ctx.logger.error('Image upload or conversion failed:', err);
throw new Error('Image processing failed: ' + err.message);
}
}
}

module.exports = HomeService;

作者:李剑一
来源:juejin.cn/post/7503017777064362010
收起阅读 »

Vue3 首款 3D 数字孪生编辑器 正式开源!

web
作者:前端开发爱好者 对于多数前端开发者而言,用 ThreeJS 打造炫酷的数字孪生场景并非易事,需掌握大量专业知识。 如今,一款基于 Vue3、ThreeJS 和 Naive UI 的数字孪生开发框架 ——Astral 3D Editor 正式开源,为 W...
继续阅读 »

作者:前端开发爱好者


对于多数前端开发者而言,用 ThreeJS 打造炫酷的数字孪生场景并非易事,需掌握大量专业知识。


图片


如今,一款基于 Vue3ThreeJS 和 Naive UI 的数字孪生开发框架 ——Astral 3D Editor 正式开源,为 Web3D 开发带来新转机。


Astral 3D Editor 是什么?


Astral 3D Editor 是一款免费开源的三维可视化孪生场景编辑器,主要服务于 Web3D 开发,支持多种常见 3D 模型格式


图片


还具备轻量化 BIM 模型解析及 CAD 图纸预览功能。


图片


Astral 3D Editor 的优势



  • 功能丰富 :支持多种 3D 模型格式,可导入导出多类型模型,方便资源整合。它还提供插件系统,可扩展更多功能。同时,支持在线预览 BIM 模型和 CAD 图纸,为建筑、工程等领域提供便利。粒子系统、动画编辑器等功能一应俱全,满足多样化创作需求。

  • 技术先进 :以 ThreeJS 为底层 3D 渲染库,结合 Vue3 响应式编程和组件化开发,以及 Naive UI 的丰富组件,构建高效稳定的编辑器框架。其场景数据无损压缩和网络分包渐进存取技术,优化了大规模场景的加载效率。

  • 开发门槛低 :作为 3D 低代码创作工具,降低了 Web3D 开发难度,前端开发者无需深入掌握 3D 图形学知识,也能快速创建高质量 3D 场景,提高开发效率。

  • 开源友好 :采用 Apache-2.0 License 开源协议,吸引众多开发者参与,形成活跃开源社区,便于交流分享和共同推动项目发展。


Astral 3D Editor 快速上手


环境准备


在开始使用 Astral 3D Editor 之前,确保已经安装了以下软件和工具:



  • Node.js :建议安装 Node.js ≥ 18.x,可以通过官方链接下载安装。

  • Yarn :一个高效的包管理工具,可以通过官方链接进行安装。


图片


项目克隆与安装


通过 Git 将 Astral 3D Editor 的项目代码克隆到本地:


git clone https://github.com/mlt131220/Astral3DEditor.git

进入项目目录


cd Astral3DEditor

使用 Yarn 安装项目依赖:


yarn install

项目运行与构建


在开发环境中启动项目:


yarn run dev

这将启动本地开发服务器,通常会自动在浏览器中打开 Astral 3D Editor 的界面,若未自动打开,可在浏览器中访问 http://localhost:3000


基础操作指南


Astral 3D Editor 的界面简洁直观,主要包含以下几个关键区域:



  • 工具栏 :提供了各种工具按钮,可进行模型导入、视图切换、对象选择和变换等操作。


图片



  • 视图区域 :用于显示和编辑 3D 场景,支持多种视图模式,如透视图、正交图,以及前置、后置、左置、右置等不同视角的切换。


图片



  • 属性面板 :用于查看和编辑当前选中对象的属性,可根据不同对象类型进行相应属性的调整。


图片


Astral 3D Editor 在线编辑器


Astral 3D Editor 的在线编辑器是其一大亮点,提供了便捷的在线 3D 场景创作体验。


图片


在线编辑器无需安装额外软件,只要有浏览器和网络连接,用户就能直接在浏览器中打开: https://editor.astraljs.com/#/,随时随地进行 3D 场景的创作和编辑。


图片


界面设计简洁直观,操作流程简单易懂,降低了学习成本,初学者也能快速上手,轻松进行模型导入、场景编辑、动画添加等操作,迅速构建出想要的 3D 场景。


图片


此外,在线编辑器还具有出色的跨平台兼容性,支持在 WindowsmacOS 以及 Linux 等多种操作系统上运行,兼容各大主流浏览器,包括 ChromeFirefoxSafari 等,用户可自由选择浏览器进行创作。


值得一提的是,在线编辑器支持通过拖拉拽形式创建场景,操作简单直观,大大降低了 3D 场景创作的门槛。


同时,官方还提供了大量可视化案例展示,这些案例不仅丰富多样,而且具有很高的学习价值,可供用户参考学习,帮助用户更好地掌握 3D 场景创作的技巧和方法。


Astral 3D Editor 的开源,为 Web3D 领域注入新活力。


其功能、技术、开发难度、应用场景和开源优势,使其有望在数字孪生领域发挥重要作用,推动 Web3D 技术持续进步。



作者:独立开阀者_FwtCoder
来源:juejin.cn/post/7497821254205816858
收起阅读 »

✨说说私活,前端特效开发,以及报价策略✨

web
为啥要写 最能唬人的前端工种是啥?最能出活的前端模块是啥?跟大家讲,真的是搞动画那一块,搞特效那一块,搞3d、webgl那一块。出活,真的出活。 吭哧吭哧一些高深的框架或者死磕一个难啃的技术硬骨头老半天,给不懂技术的人看,他未必能懂,可能他还会心想:”做老半天...
继续阅读 »

为啥要写


最能唬人的前端工种是啥?最能出活的前端模块是啥?跟大家讲,真的是搞动画那一块,搞特效那一块,搞3d、webgl那一块。出活,真的出活。


吭哧吭哧一些高深的框架或者死磕一个难啃的技术硬骨头老半天,给不懂技术的人看,他未必能懂,可能他还会心想:”做老半天,啊?就这...要我来我也会(翻白眼🙄)“。真是这样的。


所以说,唬得住人的,绝b有视觉滚动这一块。


搜一些外国佬的一些产品官网,十个有八个是类似这样的。我们熟悉的苹果官网也是这样的。


封面图.gif


封面图.gif


再多的,我不举例了。


那身为前端的,切图仔的,小卡拉米的,千千万万个我,绝不能说不会。一般人会看到一个官网有这种视差效果,就会打开给你看,问:”你说你是前端,那你会做这个效果吗“。


这个时候,咱一定得把这个ac中间那个字母给支棱起来。不能丢了面。去搜库搜包,借助工具给它搞出来。


不要说不行


要做这种视觉滚动效果,给jym推荐一个库,啥库呢?gsap


这个玩意,能通过最少的代码实现令人惊叹的滚动动画。


外国佬很多网站,甚至我们国内很多官网,搞这种装ac中间那个字母的官网,离不开这个库。


咋用


写原生,不搞框架的:


就用cdn,引就行了:


<script src="https://cdn.jsdelivr.net/npm/gsap@3.12/dist/gsap.min.js"></script>

引完这个gsap的库的js呢,完呢,再引入一个插件,叫ScrollTrigger,两者一结合,啥滚动动画能做不出来你说,官网都说了:GSAP的ScrollTrigger插件通过最少的代码实现令人惊叹的滚动动画。


<html lang="en">
// 每个section都有个背景,和一个h1的文字
<section>
<h1>111</h1>
</section>
<section>
<h1>222</h1>
</section>
<section>
<h1>333</h1>
</section>

<body>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/ScrollTrigger.min.js"></script>
</body>
</html>

上面就把html那一块写完了,接着写js:


const sections = document.querySelectorAll('section'); // 获取页面中所有的<section>元素

sections.forEach(se => {
// 从初始到目标的动画
gsap.fromTo(se, {
backgroundPositionY: `-${window.innerHeight / 2}px`, // 向上偏移(初始背景垂直位置为视口高度一般的负值)
}, {
backgroundPositionY: `${window.innerHeight / 2}px`, // 向下偏移(视口高度的一般的正值)
duration: 3, // 动画持续3s
ease: 'none', // 线性曲线
})
})

接下来用到ScrollTrigger插件:


const sections = document.querySelectorAll('section'); // 获取页面中所有的<section>元素

gsap.registerPlugin(ScrollTrigger); // gsap注册插件ScrollTrigger

sections.forEach(se => {
// 从初始到目标的动画
gsap.fromTo(se, {
backgroundPositionY: `-${window.innerHeight / 2}px`, // 向上偏移(初始背景垂直位置为视口高度一般的负值)
}, {
backgroundPositionY: `${window.innerHeight / 2}px`, // 向下偏移(视口高度的一般的正值)
duration: 3, // 动画持续3s
ease: 'none', // 线性曲线
scrollTrigger: {
trigger: se, // 触发的是当前的section
scrub: true, // 按滚动条去做这个视觉效果
}
})
})

总结


好了,如果你觉得神奇,停止往下看,自个儿去官网瞅一眼,别听我在这瞎叨叨,自己去瞅一眼,它啊,不单单是我说的这种视差滚动效果可以做,还有很多动画可以搞,它是一个动画库,我只是说的其一。


动画滴本质实质上就是数字的变动。动来动去就有了动画。这个库帮我们做了很多活,我们拿来用,我们就关注应该怎么变动数字,哪些数字,就完事。前人栽树,后人乘凉,有时候有效地聪明地灵活地有思路地去运用一些库一些包会让事情事半功倍,让效率提升,让时间缩减,让效果更美妙。


特效


往往,玩这些特效的,💰会报得高:



  • 视差滚动(Parallax Scrolling):通过背景层与内容层滚动速度差异营造空间感,适用于官网、营销页等场景,开发成本低但感知价值高。

    • 3D交互(Three.js/WebGL):如产品展示、虚拟展厅等,结合Canvas或WebGL实现,技术门槛较高但报价可达数万元。

    • 粒子动画(Particles.js/GSAP):用于登录页、Loading动画等,开发周期短但视觉效果突出,适合按模块打包报价。

    • SVG路径动画:适用于图标、数据可视化等场景,通过GreenSock等工具实现,复杂度可控且客户感知专业性强。




推荐大家工具,或许对你当前比较恼火的无法着手的特效,可能有思路:GSAP(动画库)、Three.js(3D渲染)、Pixi.js(2D渲染)。


报价策略:优先选择视觉效果显著、开发效率高的特效类型(如视差滚动、3D交互),报价可以溢出点,客户会觉得干了很多活才出这么牛的效果,交付的时候,特别是官网,也不会说太干巴巴,就只是图垒字,字垒图这种。有动画的官网,会使得更多的阅览量,触动更多的购买欲。


作者:小old弟
来源:juejin.cn/post/7495938507212177448
收起阅读 »

阮一峰推荐to-unocss,尤雨溪点赞

web
最近投稿了阮一峰周刊 to-unocss 的网站。 这个网站可以直接将 style 转换成 UnoCss 语法, 他内部使用了 transform-to-unocss 的库,可以直接将 SFC 页面转换成 UnoCss SFC,这个特性,它能带来的收益就是,...
继续阅读 »

image.png


最近投稿了阮一峰周刊 to-unocss 的网站。


image.png
这个网站可以直接将 style 转换成 UnoCss 语法, 他内部使用了 transform-to-unocss 的库,可以直接将 SFC 页面转换成 UnoCss SFC,这个特性,它能带来的收益就是,可以将你原本 sasslessclassinline-style 转换成 UnoCss 然后原子化这样 class 能够最大限度被复用,对于 inline-style 被转换后还能带来性能收益,其他转换后能极大程度上减少打包后 css 体积, 当然 transform-to-unocss 底层还有一个 ]transform-to-unocss-core 的支持,能够将一长串的 style 输入,编译成 UnoCss 语法的输出,他已经用在用户量不错的 figma 插件的 fubukicss-tool 之中,当然处理 UnoCss,我同样做了 Tailwind 的一套,如果你是 UnoCss 新手,你一定要试试这个网站 to-unocss ,如果你是 vscode 用户可以按住 Unot 这个插件,他提供了 vscode hover inline-style 提示转换和整个 SFC page 转换的强大能力


视频演示: http://www.bilibili.com/video/BV1HY…


作者:Simon_He
来源:juejin.cn/post/7499251236128342067
收起阅读 »

2025 跨平台框架更新和发布对比,这是你没看过的全新版本

web
2025 年可以说又是一个跨平台的元年,其中不妨有「鸿蒙 Next」 平台刺激的原因,也有大厂技术积累“达到瓶颈”的可能,又或者“开猿截流、降本增笑”的趋势的影响,2025 年上半年确实让跨平台框架又成为最活跃的时刻,例如: Flutter Platform...
继续阅读 »

2025 年可以说又是一个跨平台的元年,其中不妨有「鸿蒙 Next」 平台刺激的原因,也有大厂技术积累“达到瓶颈”的可能,又或者“开猿截流、降本增笑”的趋势的影响,2025 年上半年确实让跨平台框架又成为最活跃的时刻,例如:



而本篇也是基于上面的内容,对比当前它们的情况和未来可能,帮助你在选择框架时更好理解它们的特点和差异。



就算你不用,也许面试的时候就糊弄上了



Flutter


首先 Flutter 大家应该已经很熟悉了,作为在「自绘领域」坚持了这么多年的跨平台框架,相信也不需要再过多的介绍,因为是「自绘」和 「AOT 模式」,让 Flutter 在「平台统一性」和「性能」上都有不错的表现。



开发过程过程中的 hotload 的支持程度也很不错。



而自 2025 以来的一些更新也给 Flutter 带来了新的可能,比如 Flutter Platform 和 UI 线程合并 ,简单来说就是以前 Dart main Thread 和 Platform UI Thread 是分别跑在独立线程,它们的就交互和数据都需要经过 Channel 。



而合并之后,Dart main 和 Platform UI 在 Engine 启动完成后会合并到一个线程,此时 Dart 和平台原生语言就支持通过同步的方式去进行调用,也为 Dart 和 Kotlin/Java,Swift/OC 直接同步互操作在 Framework 提供了进一步基础支持。



当然也带来一些新的问题,具体可见线程合并的相关文章。



另外在当下,其实 Flutter 的核心竞争力是 Impeller ,因为跨平台框架不是系统“亲儿子”,又是自绘方案,那么在性能优化上,特别 iOS 平台,就不得不提到着色器预热或者提前编译。



传统 Skia 需要把「绘制命令」编译成可在 GPU 执行代码的过程,一般叫做着色器编译, Skia 需要「动态编译」着色器,但是 Skia 的着色器「生成/编译」与「帧工作」是按顺序处理,如果这时候着色器编译速度不够快,就可能会出现掉帧(Jank)的情况,这个我们也常叫做「着色器卡顿」



而 Impeller 正是这个背景的产物,简单说,App 所需的所有着色器都在 Flutter 引擎构建时进行离线编译,而不是在应用运行时编译


image-20250515102018153


这其实才是目前是 Flutter 的核心竞争力,不同于 Skia 需要考虑多场景和平台通用性,需要支持各种灵活的额着色器场景,Impeller 专注于 Flutter ,所以它可以提供更好的专注支持和问题修复,更多可见:着色器预热?为什么 Flutter 需要?



当然 Skia 也是 Google 项目,对于着色器场景也有 Graphite 后端在推进支持,它也在内部也是基于 Impeller 为原型去做的改进,所以未来 Skia 也可以支持部分场景的提前编译。



而在鸿蒙平台,华为针对 Flutter 在鸿蒙的适配,在华为官方过去的分享里,也支持了 Flutter引擎Impeller鸿蒙化,详细可见:b23.tv/KKNDAQB


甚至,Flutter 在类游戏场景支持也挺不错,如果配合 rive 的状态机和自适应,甚至可以开发出很多出乎意料的效果,而官方也有 Flutter 的游戏 SDK 或者 Flame 第三方游戏包支持:



最后,那么 Flutter 的局限性是什么呢?其实也挺多的,例如:



  • 文字排版能力不如原生

  • PC平台推进交给了 Canonical 团队负责,虽然有多窗口雏形,但是推进慢

  • 不支持官方热更新,shorebird 国内稳定性一般

  • 内存占用基本最高

  • Web 只支持 wasm 路线

  • 鸿蒙版本落后主版本太多

  • 不支持小程序,虽然有第三方实现,但是力度不大

  • ····



所以,Flutter 适合你的场景吗?



React Native


如果你很久没了解过 RN ,那么 2025 年的 RN 会超乎你的想象,可以说 Skia 和 WebGPU 给了它更多的可能。


img


RN 的核心之一就是对齐 Web 开发体验,其中最重要的就是 0.76 之后 New Architecture 成了默认框架,例如 Fabric, TurboModules, JSI 等能力解决了各种历史遗留的性能瓶颈,比如:



  • JSI 让 RN 可以切换 JS 引擎,比如 Chakrav8Hermes ,同时允许 JS 和 Native 线程之间的同步相互执行

  • 全新的 Fabric 取代了原本的 UI Manager,支持 React 的并发渲染能力,特别是现在的新架构支持 React 18 及更高版本中提供的并发渲染功能,对齐 React 最新版本,比如 Suspense & Transitions:

  • Hermes JS 引擎预编译的优化字节码,优化 GC 实现等

  • TurboModules 按需加载插件

  • ····


另外现在新版 RN 也支持热重载,同时可以更快对齐新 React 特性,例如 React 19 的 Actions、改进的异步处理等 。


而另一个支持就是 RN 在 Skia 和 WebGPU 的探索和支持,使用 Skia 和 WebGPU 不是说 RN 想要变成自绘,而是在比如「动画」和「图像处理」等场景增加了强力补充,比如:



React Native Skia Video 模块,实现了原生纹理(iOS Metal, Android OpenGL)到 React Native Skia 的直接传输,优化了内存和渲染速度,可以被用于视频帧提取、集成和导出等,生态中还有 React Native Vision Camera 和 React Native Video (v7) 等支持 Skia 的模块:



还有是 React Native 开始引入 WebGPU 支持,其效果将确保与 Web 端的 WebGPU API 完全一致,允许开发者直接复制代码示例的同时,实现与 Web Canvas API 对称的 RN Canvas API



最后,WebGPU 的引入还可以让 React Native 开发者能够利用 ThreeJS 生态,直接引入已有的 3D 库,这让 React Native 的能力进一步对齐了 Web :



最后,RN 也是有华为推进的鸿蒙适配,会采用 XComponent 对接到 ArkUI 的后端接口进行渲染,详细可见:鸿蒙版 React Native 正式开源


而在 PC 领域 RN 也有一定支持,比如微软提供的 windows 和 macOS 支持,社区提供的 web 和 Linux 支持,只是占有并不高,一般忽略。


而在小程序领域,有京东的 Taro 这样的大厂开源支持,整体在平台兼容上还算不错。



当然,RN 最大的优势还在于成熟的 code-push 热更新支持。



那么使用 RN 有什么局限性呢?最直观的肯定是平台 UI 的一致性和样式约束,这个是 OEM 框架的场景局限,而对于其他的,目前存在:



  • 第三方库在新旧框架支持上的风险

  • RN 版本升级风险,这个相信大家深有体会

  • 平台 API 兼容复杂度较高

  • 0.77 之后才支持 Google Play 的 16 KB 要求

  • 可用性集中在 Android 和 iOS ,鸿蒙适配和维度成本更高

  • 小程序能力支持和客户端存在一定割裂

  • ····


事实上, RN 是 Cordova 之后我接触的第一个真正意义上的跨平台框架,从我知道它到现在应该有十年了,那么你会因为它的新架构和 WebGPU 能力而选择 RN 么?


更多可见:



Compose Multiplatform


Compose Multiplatform(CMP) 近期的热度应该来自 Compose Multiplatform iOS 稳定版发布 ,作为第二个使用 Skia 的自绘框架,除了 Web 还在推进之外, CMP 基本完成了它的跨平台稳定之路。




Compose Multiplatform(CMP) 是 UI,Kotlin Multiplatform (KMP) 是语言基础。



CMP 使用 Skia 绘制 UI ,甚至在 Android 上它和传统 View 体系的 UI 也不在一个渲染树,并且 CMP 通过 Skiko (Skia for Kotlin) 这套 Kotlin 绑定库,进而抹平了不同架构(Kotlin Native,Kotlin JVM ,Kotlin JS,Kotlin wasm)调用 skia 的差异。


所以 CMP 的优势也来自于此,它可以通过 skia 做到不同平台的 UI 一致性,并且在 Android 依赖于系统 skia ,所以它的 apk 体积也相对较小,而在 PC 平台得益于 JVM 的成熟度,CMP 目前也做到了一定的可用程度。


其中和 Android JVM 模式不同的是,Kotlin 在 iOS 平台使用的是 Kotlin/Native ,Kotlin/Native 是 KMP 在 iOS 支持的关键能力,它负责将 Kotlin 代码直接编译为目标平台的机器码或 LLVM 中间表示 (IR),最终为 iOS 生成一个标准 .framework ,这也是为什么 Compose iOS 能实现接近原生的性能。



实现鸿蒙支持目前主流方式也是 Kotlin/Native ,不得不说 Kotlin 最强大的核心价值不是它的语法糖,而是它的编译器,当然也有使用 Kotlin/JS 适配鸿蒙的方案。



所以 CMP 最大的优势其实是 Kotlin ,Kotlin 的编译器很强大,支持各种编译过程和产物,可以让 KMP 能够灵活适配到各种平台,并且 Kotlin 语法的优势也让使用它的开发者忠诚度很高。


不过遗憾的是,目前 CMP 鸿蒙平台的适配上都不是 Jetbrains 提供的方案,华为暂时也没有 CMP 的适配计划,目前已知的 CMP/KMP 适配基本是大厂自己倒腾的方案,有基于 KN 的 llvm 方案,也有基于 Kotlin/JS 的低成本方案,只是大家的路线也各不相同。



在小程序领域同样如此。



另外现在 CMP 开发模式下的 hot reload 已经可以使用 ,不过暂时只支持 desktop,原理大概是只支持 jvm 模式。


而在社区上,klibs.io 的发布也补全了 Compose Multiplatform 在跨平台最后一步,这也是 Compose iOS 能正式发布的另外一个原因:



那么聊到这里,CMP 面临的局限性也很明显:



  • 鸿蒙适配成本略高,没有官方支持,低成本可能会选择 Kotlin/JS,为了性能的高成本可能会考虑 KN,但是 KN 在 iOS 和鸿蒙的 llvm 版本同步适配也是一个需要衡量的成本

  • 小程序领域需要第三方支持

  • iOS 平台可能面临的着色器等问题暂无方案,也许未来等待 Skia 的 Graphite 后端

  • 在 Android JVM 模式和 iOS 的 KN 模式下,第三方包适配的难度略高

  • hotload 暂时只支持 PC

  • 桌面内存占用问题-

  • 没有官方热更新条件

  • kjs、kn、kjvm、kwasm 之间的第三方包兼容支持问题

  • ····


相信 2025 年开始,CMP 会是 Android 原生开发者在跨平台的首选之一,毕竟 Kotlin 生态不需要额外学习 Dart 或者 JS 体系,那么你会选择 CMP 吗?


Kuikly


Kuikly 其实也算是 KMP 体系的跨平台框架,只是腾讯在做它的时候还没 CMP ,所以一开始 Kuikly 是通过 KMM 进行实现,而后在 UI 层通过自己的方案完成跨平台



这其实就是 Kuikly 和 CMP 最大的不同,底层都是 KMP 方案,但是在绘制上 Kuikly 采用的是类 RN 的方式,目前 Kuikly 主要是在 KMP 的基础上实现的自研 DSL 来构建 UI ,比如 iOS 平台的 UI 能力就是 UIkit ,而大家更熟悉的 Compose 支持,目前还处于开发过程中:




SwiftUI 和 Compose 无法直接和 Kuikly 一起使用,但是 Kuikly 可以在 DSL 语法和 UI 组件属性对齐两者的写法,变成一个类 Compose 和 SwiftUI 的 UI 框架,也就是 Compose DSL 大概就是让 Kuikly 更像 Compose ,而不是直接适配 Compose



那么,Kuikly 和 RN 之间又什么区别?


第一,Kuikly 支持 Kotlin/JS 和 Kotlin/Native 两种模式,也就是它可以支持性能很高的 Native 模式


第二,Kuikly 实现了自己的一套「薄原生层」,Kuikly 使用“非常薄”的原生层,该原生层只暴露最基本和无逻辑的 UI 组件(原子组件),也就是 Kuikly 在 UI 上只用了最基本的原生层 UI ,真正的 UI 逻辑主要在共享的 Kotlin 代码来实现:



通过将 UI 逻辑抽象到共享的 Kotlin 层,减少平台特定 UI 差异或行为差异的可能性,「薄原生层」充当一致的渲染目标,确保 Kotlin 定义的 UI 元素在所有平台上都以类似的方式显示。




也就是说,Kuikly 虽然会依赖原生平台的控件,但是大部分控件的实现都已经被「提升」到 Kuikly 自己的 Kotlin 共享层,目前 Kuikly 实现了 60% UI 组件的纯 Kotlin 组合封装实现,不需要 Native 提供原子控件



另外 Kuikly 表示后续会支持全平台小程序,这也是优势之一。



最后,Kuikly 还在动态化热更新场景, 可以和自己腾讯的热更新管理平台无缝集成,这也是优势之一。


那么 Kuikly 存在什么局限性?首先就是动态化场景只支持 Kotlin/JS,而可动态化类型部分:



  • 不可直接依赖平台能力

  • 不可使用多线程和协程

  • 不可依赖内置部分


其他的还有:



  • UI 不是 CMP ,使用的是类 RN 方式,所谓需要稍微额外理解成本

  • 不支持 PC 平台

  • 基于原生 OEM,虽然有原子控件,但是还是存在部分不一致情况

  • 在原有 App 集成 Kuikly ,只能把它简单当作如系统 webview 的概念来使用



另外,腾讯还有另外一个基于 CMP 切适配鸿蒙的跨平台框架,只是何时开源还尚不明确



那么,你会为了小程序和鸿蒙而选择 Kuikly 吗?


更多可见:腾讯 Kuikly 正式开源


Lynx


如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web 前端的跨平台全家桶



目前 Lynx 开源的首个支持框架就是基于 React 的 ReactLynx,当然官方也表示Lynx 并不局限于 React,所以不排除后续还有 VueLynx 等其他框架支持,而 Lynx 作为核心引擎支持,其实并不绑定任何特定前端框架,只是当前你能用的暂时只有 ReactLynx :



而在实现上,源代码中的标签,会在运行时被 Lynx 引擎解析,翻译成用于渲染的 Element,嵌套的 Element 会组成的一棵树,从而构建出UI界面:



所以从这里看,初步开源的 Lynx 是一个类 RN 框架,不过从官方的介绍“选择在移动和桌面端达到像素级一致的自渲染” ,可以看出来宣传中可以切换到自渲染,虽然暂时还没看到。


而对于 Lynx 主要的技术特点在于:



  • 「双线程架构」,思路类似 react-native-reanimated ,JavaScript 代码会在「主线程」和「后台线程」两个线程上同时运行,并且两个线程使用了不同的 JavaScript 引擎作为其运行时:

  • 另外特点就是 PrimJS ,一个基于 QuickJS 深度定制和优化的 JavaScript 引擎,主要有模板解释器(利用栈缓存和寄存器优化)、与 Lynx 对象模型高效集成的对象模型(减少数据通信开销)、垃圾回收机制(非 QuickJS 的引用计数 RC,以提升性能和内存分析能力)、完整实现了 Chrome DevTools Protocol (CDP) 以支持 Chrome 调试器等

  • “Embedder API” 支持直接与原生 API 交互 ,提供多平台支持


所以从 Lynx 的宏观目标来看,它即支持类 RN 实现,又有自绘计划,同时除了 React 模式,后期还适配 Vue、Svelte 等框架,可以说是完全针对 Web 开发而存在的跨平台架构。



另外支持平台也足够,Android、iOS、鸿蒙、Web、PC、小程序都在支持列表里。



最后,Lynx 对“即时首帧渲染 (IFR)”和“丝滑流畅”交互体验有先天优势,开发双线程模型及主线程脚本 (MTS) 让 Lynx 的启动和第一帧渲染速度还挺不错,比如:



  • Lynx 主线程负责处理直接处理屏幕像素渲染的任务,包括:执行主线程脚本、处理布局和渲染图形等等,比如负责渲染初始界面和应用后续的 UI 更新,让用户能尽快看到第一屏内容

  • Lynx 的后台线程会运行完整的 React 运行时,处理的任务不直接影响屏幕像素的显示,包括在后台运行的脚本和任务(生命周期和其他副作用),它们与主线程分开运行,这样可以让主线程专注于处理用户交互和渲染,从而提升整体性能


而在多平台上,Lynx 是自主开发的渲染后端支持 Windows、tvOS、MacOS 和 HarmonyOS ,但是不确实是否支持 Linux:



那 Lynx 有什么局限性?首先肯定是它非常年轻,虽然它的饼很大,但是对应社区、生态系统、第三方库等都还需要时间成长。



所以官方也建议 Lynx 最初可能更适合作为模块嵌入到现有的原生应用中,用于构建特定视图或功能,而非从零开始构建一个完整的独立应用



其次就是对 Web 前端开发友好,对客户端而言学习成本较高,并且按照目前的开源情况,除了 Android、iOS 和 Web 的类 RN 实现外,其他平台的支持和自绘能力尚不明确:


=


最后,Lynx 的开发环境最好选 macOS,关于 Windows 和 Linux 平台目前工具链兼容性还需要打磨


那么,总结下来,Lynx 应该会是前端开发的菜,那你觉得 Lynx 是你的选择么?


更多可见:字节跨平台框架 Lynx 开源


uni-app x


说到 uni-app 大家第一印象肯定还是小程序,而虽然 uni-app 也可以打包客户端 app,甚至有基于 weex 的 nvue 支持,但是其效果只能说是“一言难尽”,而这里要聊的 uni-app x ,其实就是 DCloud 在跨平台这两年的新尝试。


具体来说,就是 uni-app 不再是运行在 jscore 的跨平台框架,它是“基于 Web 技术栈开发,运行时编译为原生代码”的模式,相信这种模式大家应该也不陌生了,简单说就是:js(uts) 代码在打包时会直接编译成原生代码:


目标平台uts 编译后的原生语言
AndroidKotlin
iOSSwift
鸿蒙ArkTS
Web / 小程序JavaScript

甚至极端一点说,uni-app x 可以不需要单独写插件去调用平台 API,你可以直接在 uts 代码里引用平台原生 API ,因为你的代码本质上也是会被编译成原生代码,所以 uts ≈ native code ,只是使用时需要配置上对应的条件编译(如 APP-ANDROIDAPP-IOS )支持:


import Context from "android.content.Context";
import BatteryManager from "android.os.BatteryManager";

import { GetBatteryInfo, GetBatteryInfoOptions, GetBatteryInfoSuccess, GetBatteryInfoResult, GetBatteryInfoSync } from '../interface.uts'
import IntentFilter from 'android.content.IntentFilter';
import Intent from 'android.content.Intent';

import { GetBatteryInfoFailImpl } from '../unierror';

/**
* 获取电量
*/

export const getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInfoOptions) {
 const context = UTSAndroid.getAppContext();
 if (context != null) {
   const manager = context.getSystemService(
     Context.BATTERY_SERVICE
  ) as BatteryManager;
   const level = manager.getIntProperty(
     BatteryManager.BATTERY_PROPERTY_CAPACITY
  );

   let ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
   let batteryStatus = context.registerReceiver(null, ifilter);
   let status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
   let isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;

   const res : GetBatteryInfoSuccess = {
     errMsg: 'getBatteryInfo:ok',
     level,
     isCharging: isCharging
  }
   options.success?.(res)
   options.complete?.(res)
} else {
   let res = new GetBatteryInfoFailImpl(1001);
   options.fail?.(res)
   options.complete?.(res)
}
}



比如上方代码,通过 import BatteryManager from "android.os.BatteryManager" 可以直接导入使用 Android 的 BatteryManager 对象。


可以看到,在 uni-app x 你是可以“代码混写”的,所以与传统的 uni-app 不同,uni-app 依赖于定制 TypeScript 的 uts 和 uvue 编译器:



  • uts 和 ts 有相同的语法规范,并支持绝大部分 ES6 API ,在编译时会把内置的如ArrayDateJSONMapMathString 等内置对象转为 Kotlin、Swift、ArkTS 的对象等,所以也不需要有 uts 之类的虚拟机,另外 uts 编译器在处理特定平台时,还会调用相应平台的原生编译器,例如 Kotlin 编译器和 Swift 编译器

  • uvue 编译器基于 Vite 构建,并对它进行了扩展,大部分特性(如条件编译)和配置项(如环境变量)与 uni-app 的 Vue3 编译器保持一致,并且支持 less、sass、ccss 等 CSS 预处理器,例如 uvue 的核心会将开发者使用 Vue 语法和 CSS 编写的页面,编译并渲染为 ArkUI


而在 UI 上,目前除了编译为 ArkUI 之外,Android 和 iOS 其实都是编译成原生体系,目前看在 Android 应该是编译为传统 View 体系而不是 Compose ,而在 iOS 应该也是 UIKit ,按照官方的说法,就是性能和原生相当


所以从这点看,uni-app x 是一个类 RN 的编译时框架,所以,它的局限性问题也很明显,因为它的优势在于编译器转译得到原生性能,但是它的劣势也是在于转译



  • 不同平台翻译成本较高,并不支持完整的语言,阉割是必须的,API 必然需要为了转译器而做删减,翻译后的细节对齐于优化会是最大的挑战

  • iOS 平台还有一些骚操作,保留了可选 js 老模式和新 swift 模式,核心是因为插件生态,官方表示 js 模式可以大幅降低插件生态的建设难度, 插件作者只需要特殊适配 Android 版本,在iOS和Web端仍使用 ts/js 库,可以快速把 uni-app/web 的生态迁移到 uni-app x

  • 生态支持割裂,uni-app 和 uni-app x 插件并不通用

  • 不支持 PC

  • HBuilderX IDE

  • ·····


那么,你觉得 uni-app x 会是你跨平台选择之一么?


更多可见:uni-app x 正式支持鸿蒙


最后


最后,我们简单做个总结:


框架 (Framework)开发语言渲染方式特点缺点支持平台维护企业
FlutterDart自绘,Impeller自绘,多平台统一,未来支持 dart 和平台语言直接交互,Impeller 提供竞争力,甚至支持游戏场景占用内存大,文本场景略弱,Impeller 还需要继续打磨android、iOS、Web、Windows、macOS、Linux、鸿蒙(华为社区提供)Google
React NativeJS 体系原生 OEM + Skia/WebGPU 支持新架构提供性能优化,对齐 Web,引入 skia 和 webGPU 补充,code-push 热更新UI 一致性和新旧架构的第三方支持android、iOS、鸿蒙(华为社区提供),额外京东 Taro 支持小程序,web、windows、macOS、Linux 第三方支持Facebook
Compose MultiplatformKotlin体系Skia 自绘Kotlin 体系,skia 自绘,多平台统一,支持 kn、kjs、kwasm 、kjvm 多种模式KN  JVM、JS、Wasm 生态需要整合,没有着色器预编方案android、iOS、Web、Windows、macOS、LinuxJetbrains
KuiklyKotlin体系原生 OEM ,「薄原生层」基于 KMP 的类 RN 方案,在动态化有优势小部分 UI 一致性场景,UI 与 CMP 脱轨android、iOS、Web、鸿蒙、小程序腾讯
LynxJS 体系原生 OEM,未来也有自绘对齐 Web 开发首选,秒开优化,规划丰富非常早期 ,生态发展中,客户端不友好android、iOS、Web、Windows、macOS、鸿蒙、小程序字节
uni-app xuts原生 OEM,直接翻译为原生语言支持混写 uts 和原生代码,直接翻译为原生生态插件割裂,UI 一致性问题,翻译 API 长期兼容成本android、iOS、Web、鸿蒙、小程序DCloud

什么,你居然看完了?事实上我写完都懒得查错别字了,因为真的太长了。


作者:恋猫de小郭
来源:juejin.cn/post/7505578411492474915
收起阅读 »

前端何时能出个"秦始皇"一统天下?我是真学不动啦!

web
前端何时能出个"秦始皇"一统天下?我是真学不动啦! 引言 前端开发的世界,就像历史上的战国时期一样,各种框架、库、工具层出不穷,形成了一个百花齐放但也令人眼花缭乱的局面。 而且就因为百家争鸣,导致各种鄙视链出现 比如 React 和 Vue 互喷 v:你re...
继续阅读 »

前端何时能出个"秦始皇"一统天下?我是真学不动啦!


引言


前端开发的世界,就像历史上的战国时期一样,各种框架、库、工具层出不穷,形成了一个百花齐放但也令人眼花缭乱的局面。




而且就因为百家争鸣,导致各种鄙视链出现


比如 React 和 Vue 互喷


v:你react 这么难用,不如我vue 简单


r:你一点都不灵活,我想咋用咋用


v:你useEffect 心智负担太重,一点都好用


r:啥心智负担,那是你太笨了,我就喜欢这种什么都掌握在自己手里的感觉


v:你内部更是混乱,一个状态管理就那么多种 啥redux、mobx、recoil。。。。不像我们一个pinia 走天下


r:你管我 我想用哪个用哪个,你还说我,你内部对一个 用ref还是用reactive 都吵得不可开交!


......


2.jpeg


1. 框架之争



  • React: 由Facebook维护的一个用于构建用户界面的JavaScript库。其设计理念是通过组件化的方式简化复杂的UI开发。




  • Vue.js: 一种渐进式JavaScript框架,非常适合用来构建单页应用。Vue的核心库只关注视图层,易于上手。




  • Angular: Google支持的一个开源Web应用框架,适用于大型企业级项目。它提供了一个全面的解决方案来创建动态Web应用程序。




  • Solid.js: 一个专注于性能和简单性的声明式UI库,采用细粒度的响应式系统,提供了极高的运行效率。




  • Svelte: 一种新兴的前端框架,通过在编译时将组件转换为高效的原生代码,从而避免了运行时开销。




  • Ember.js: 一个旨在帮助开发者构建可扩展的Web应用的框架,尤其适合大型团队协作。







2. 样式处理满花齐放


样式处理方面可以进一步细分,包括CSS预处理器、CSS-in-JS、Utility-First CSS框架以及CSS Modules等。



  • CSS预处理器



    • Sass: 提供变量、嵌套规则等高级功能,极大地提高了CSS代码的可维护性。


    • Less: 另一种流行的CSS预处理器,支持类似的功能但语法稍有不同。


    • Stylus: 一款灵活且功能强大的CSS预处理器,允许省略括号和分号等符号,使代码更加简洁。




  • CSS-in-JS




  • 原子化css



    • Tailwind CSS: 一种实用优先的CSS框架,让你可以通过低级实用程序类构建定制设计。




    • UnoCSS: 新一代的原子化CSS引擎,旨在提供极致的性能和灵活性。




    • Windi CSS: 一个基于Tailwind CSS的即时按需CSS框架,提供了更快的开发体验。




    • GitHub Stars: 约6.5k(截至2025年4月)






3. 构建工具五花八门


构建工具是现代前端开发不可或缺的一部分,它们负责将源代码转换为生产环境可用的形式,并优化性能。



  • Webpack: 一个模块打包工具,广泛用于复杂的前端项目中。它支持多种文件类型的处理,并具有强大的插件生态。




  • Vite: 由Vue.js作者尤雨溪开发的下一代前端构建工具,以其极快的冷启动速度和热更新闻名。




  • Rollup: 一个专注于JavaScript库的打包工具,特别适合构建小型库或框架。




  • Rspack: 一个基于Rust实现的高性能构建工具,兼容Webpack配置,旨在提供更快的构建速度。




  • esbuild: 一个用Go语言编写的极速打包工具,专为现代JavaScript项目设计。




  • Turbopack: 由Next.js团队推出的下一代构建工具,号称比Webpack快700倍。




  • Rolldown: 一个基于Rust的Rollup替代方案,旨在提供更快的构建速度和更高的性能。





对比分析:



  • Webpack 是目前最成熟的构建工具,生态系统庞大,但配置复杂度较高。

  • Vite 凭借其快速的开发体验迅速崛起,尤其在中小型项目中表现优异。

  • Rollup 更适合轻量级项目或库的构建,虽然社区规模较小,但在特定场景下非常高效。

  • Rspackesbuild 利用高性能语言(如Rust和Go)实现了极快的构建速度,适合对性能要求较高的项目。

  • Turbopack 是新兴工具,主打极速构建,未来可能成为Webpack的有力竞争者。

  • Rolldown 提供了另一种基于Rust的高速构建解决方案,特别针对Rollup用户群体。




4. 包管理工具逐步更新





5. 状态管理百家争鸣


状态管理是前端开发中的重要组成部分,它帮助开发者有效地管理应用的状态变化。





6. JavaScript运行时环境都有好几种


JavaScript运行时环境是现代前端和后端开发的核心部分,它决定了代码如何被解析和执行。以下是几种主流的JavaScript运行时环境:



  • Node.js:



    • Node.js 是一个基于Chrome V8引擎的JavaScript运行时,广泛用于构建服务器端应用、命令行工具以及全栈开发。

    • 它拥有庞大的生态系统,npm作为其默认包管理器,已经成为全球最大的软件注册表。

    • 官网: nodejs.org/

    • GitHub: github.com/nodejs/node

    • GitHub Stars: 约111k(截至2025年4月)



  • Deno:



    • Deno 是由Node.js的原作者Ryan Dahl创建的一个现代化JavaScript/TypeScript运行时,旨在解决Node.js的一些设计缺陷。

    • 它内置了对TypeScript的支持,并提供了更安全的权限模型(如文件系统访问需要显式授权)。

    • Deno还集成了标准库,无需依赖第三方模块即可完成许多常见任务。

    • 官网: deno.land/

    • GitHub: github.com/denoland/de…

    • GitHub Stars: 约103k(截至2025年4月)



  • Bun:



    • Bun 是一个新兴的JavaScript运行时,旨在提供更快的性能和更高效的开发体验。

    • 它不仅可以用作运行时环境,还可以替代npm、Yarn等包管理工具,同时支持ES Modules和CommonJS。

    • Bun的目标是成为Node.js和Deno的强大竞争者,特别适合高性能需求的场景。

    • 官网: bun.sh/

    • GitHub: github.com/oven-sh/bun

    • GitHub Stars: 约77.5k(截至2025年4月)




对比分析:



  • Node.js 是目前最成熟且广泛应用的JavaScript运行时,尤其在企业级项目中占据主导地位。

  • Deno 提供了更现代化的设计理念,特别是在安全性、TypeScript支持和内置工具方面表现突出。

  • Bun 是一个新兴的选手,凭借其极速的性能和多功能性迅速吸引了开发者关注,未来潜力巨大。




7. 跨平台开发


随着移动设备和多终端生态的普及,跨平台开发成为现代应用开发的重要方向。以下是几种主流的跨平台开发工具和技术:



  • React Native:



    • React Native 是由Facebook推出的一个基于React的跨平台移动应用开发框架,允许开发者使用JavaScript和React构建原生性能的iOS和Android应用。

    • 它提供了丰富的社区支持和插件生态,适合需要快速迭代的项目。

    • 官网: reactnative.dev/

    • GitHub: github.com/facebook/re…

    • GitHub Stars: 约122k(截至2025年4月)



  • Flutter:



    • Flutter 是由Google开发的一个开源UI框架,使用Dart语言构建高性能的跨平台应用。

    • 它通过自绘引擎渲染UI,提供了一致的用户体验,并支持Web、iOS、Android以及桌面端开发。

    • 官网: flutter.dev/

    • GitHub: github.com/flutter/flu…

    • GitHub Stars: 约170k(截至2025年4月)



  • Electron:



    • Electron 是一个用于构建跨平台桌面应用的框架,基于Node.js和Chromium,广泛应用于桌面端应用开发。

    • 它允许开发者使用Web技术(HTML、CSS、JavaScript)构建功能强大的桌面应用,但可能会导致较大的应用体积。

    • 官网: http://www.electronjs.org/

    • GitHub: github.com/electron/el…

    • GitHub Stars: 约116k(截至2025年4月)



  • Tauri:



    • Tauri 是一个轻量级的跨平台桌面应用框架,旨在替代Electron,提供更小的应用体积和更高的安全性。

    • 它利用系统的原生Webview来渲染UI,同时支持Rust作为后端语言,从而实现更高的性能。

    • 官网: tauri.app/

    • GitHub: github.com/tauri-apps/…

    • GitHub Stars: 约91.5k(截至2025年4月)



  • Capacitor:



    • Capacitor 是由Ionic团队推出的一个跨平台工具,允许开发者将Web应用封装为原生应用。

    • 它支持iOS、Android和Web,并提供了丰富的插件生态,方便调用原生设备功能。

    • 官网: capacitorjs.com/

    • GitHub: github.com/ionic-team/…

    • GitHub Stars: 约13.1k(截至2025年4月)



  • UniApp:



    • UniApp 是一个基于 Vue.js 的跨平台开发框架,能够将代码编译到多个平台,包括微信小程序、H5、iOS、Android以及其他小程序(如支付宝小程序、百度小程序等)。

    • 它的优势在于一次编写,多端运行,特别适合需要覆盖多个小程序平台的项目。

    • 官网: uniapp.dcloud.io/

    • GitHub: github.com/dcloudio/un…

    • GitHub Stars: 约40.6k(截至2025年4月)




对比分析:



  • React NativeFlutter 是移动端跨平台开发的两大主流选择,分别适合熟悉JavaScript和Dart的开发者。

  • Electron 是桌面端跨平台开发的经典解决方案,虽然体积较大,但易于上手。

  • Tauri 提供了更轻量化的桌面端开发方案,适合对性能和安全性有更高要求的项目。

  • Capacitor 则是一个灵活的工具,特别适合将现有的Web应用快速迁移到移动端。

  • UniApp 非常适合需要覆盖多种小程序平台的项目,尤其在国内的小程序生态中表现出色。




结论


你看我这还是只是列举了一部分,都这么多了,学前端的是真的命苦啊,真心学不动了。


1.jpeg


而且最近 尤雨溪宣布成立 VoidZero 说是一代JavaScript工具链,能够统一前端 开发构建工具,如果真能做到,真是一件令人振奋的事情,希望尤雨溪能做到跟 spring 一样统一java 天下 把前端的天下给统一了,大家觉得有可能么?


作者:前端摸鱼杭小哥
来源:juejin.cn/post/7493420166878822450
收起阅读 »

脱裤子放屁 - 你们讨厌这样的页面吗?

web
前言 平时在逛掘金和少数派等网站的时候,经常有跳转外链的场景,此时基本都会被中转到一个官方提供的提示页面。 掘金: 知乎: 少数派: 这种官方脱裤子放屁的行为实在令人恼火。是、是、是、我当然知道这么做有很多冠冕堂皇的理由,比如: 防止钓鱼攻击 增强用户...
继续阅读 »

前言


平时在逛掘金和少数派等网站的时候,经常有跳转外链的场景,此时基本都会被中转到一个官方提供的提示页面。


掘金:


site-juejin.png


知乎:


site-zhihu.png


少数派:


site-sspai.png


这种官方脱裤子放屁的行为实在令人恼火。是、是、是、我当然知道这么做有很多冠冕堂皇的理由,比如:



  • 防止钓鱼攻击

  • 增强用户意识

  • 品牌保护

  • 遵守法律法规

  • 控制流量去向


(以上5点是 AI 告诉我的理由)


但是作为混迹多年的互联网用户,什么链接可以点,什么最好不要点(悄悄的点) 我还是具备判断能力的。


互联网的本质就是自由穿梭,一个 A 标签就可以让你在整个互联网翱翔,现在你每次起飞的时候都被摁住强迫你阅读一次免责声明,多少是有点恼火的。


解决方案


这些中转站的实现逻辑基本都是将目标地址挂在中转地址的target 参数后面,在中转站做免责声明,然后点击继续跳转才跳到目标网站。


掘金:


https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.apple.com%2Fcn%2Fdesign%2Fhuman-interface-guidelines%2Fapp-icons%23macOS/


少数派:
https://sspai.com/link?target=https%3A%2F%2Fgeoguess.games%2F


知乎:
https://link.zhihu.com/?target=https%3A//asciidoctor.org/


所以我们就可以写一个浏览器插件,在这些网站中,找出命中外链的 A 标签,替换掉它的 href 属性(只保留 target 后面的真实目标地址)。


核心函数:



function findByTarget() {
if (!hostnames.includes(location.hostname)) return;
const linkKeyword = "?target=";
const aLinks = document.querySelectorAll(
`a[href*="${linkKeyword}"]:not([data-redirect-skipper])`
);
if (!aLinks) return;
aLinks.forEach((a) => {
const href = a.href;
const targetIndex = href.indexOf(linkKeyword);
if (targetIndex !== -1) {
const newHref = href.substring(targetIndex + linkKeyword.length);
a.href = decodeURIComponent(newHref);
a.setAttribute("data-redirect-skipper", "true");
}
});
}

为此我创建了一个项目仓库 redirect-skipper ,并且将该浏览器插件发布在谷歌商店了 安装地址


安装并启用这个浏览器插件之后,在这些网站中点击外链就不会看到中转页面了,而是直接跳转到目标网站。


因为我目前明确需要修改的就是这几个网站,如果大家愿意使用这个插件,且有其他网站需要添加到替换列表的,可以给 redirect-skipper 仓库 提PR。


如果需要添加的网站的转换规则是和 findByTarget 一致的,那么仅需更新 sites.json 文件即可。


如果需要添加的网站的转换规则是独立的,那么需要更新插件代码,合并之后,由我向谷歌商店发起更新。


为了后期可以灵活更新配置(谷歌商店审核太慢了),我默认将插件应用于所有网站,然后在代码里通过 hostname 来判断是否真的需要执行。


{
"$schema": "https://json.schemastore.org/chrome-manifest.json",
"name": "redirect-skipper",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["./scripts/redirect-skipper.js"],
"run_at": "document_end"
}
],
}

在当前仓库里维护一份 sites.json 的配置表,格式如下:


{
"description": "远程配置可以开启 Redirect-Skipper 插件的网站 (因为谷歌商店审核太慢了,否则无需通过远程配置,增加复杂性)",
"sites": [
{
"hostname": "juejin.cn",
"title": "掘金"
},
{
"hostname": "sspai.com",
"title": "少数派"
},
{
"hostname": "www.zhihu.com",
"title": "知乎"
}
]
}

这样插件在拉取到这份数据的时候,就可以根据这边描述的网站配置,决定是否执行具体代码。


插件完整代码:


function replaceALinks() {
findByTarget();
}

function observerDocument() {
const mb = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
if (mutation.addedNodes.length) {
replaceALinks();
}
}
}
});
mb.observe(document, { childList: true, subtree: true });
}

// 监听路由等事件
["hashchange", "popstate", "load"].forEach((event) => {
window.addEventListener(event, async () => {
replaceALinks();
if (event === "load") {
observerDocument();
await updateHostnames();
replaceALinks(); // 更新完数据后再执行一次
}
});
});

let hostnames = ["juejin.cn", "sspai.com", "www.zhihu.com"];

function updateHostnames() {
return fetch(
"https://raw.githubusercontent.com/dogodo-cc/redirect-skipper/master/sites.json"
)
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Network response was not ok");
})
.then((data) => {
// 如果拉到了远程数据,就用远程的
hostnames = data.sites.map((site) => {
return site.hostname;
});
})
.catch((error) => {
console.error(error);
});
}

// 符合 '?target=' 格式的链接
// https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.apple.com%2Fcn%2Fdesign%2Fhuman-interface-guidelines%2Fapp-icons%23macOS/
// https://sspai.com/link?target=https%3A%2F%2Fgeoguess.games%2F
// https://link.zhihu.com/?target=https%3A//asciidoctor.org/

function findByTarget() {
if (!hostnames.includes(location.hostname)) return;
const linkKeyword = "?target=";
const aLinks = document.querySelectorAll(
`a[href*="${linkKeyword}"]:not([data-redirect-skipper])`
);
if (!aLinks) return;
aLinks.forEach((a) => {
const href = a.href;
const targetIndex = href.indexOf(linkKeyword);
if (targetIndex !== -1) {
const newHref = href.substring(targetIndex + linkKeyword.length);
a.href = decodeURIComponent(newHref);
a.setAttribute("data-redirect-skipper", "true");
}
});
}


更详细的流程可以查看 redirect-skipper 仓库地址


标题历史



  • 浏览器插件之《跳过第三方链接的提示中转页》


作者:dogodo
来源:juejin.cn/post/7495977411273490447
收起阅读 »

前端实现画中画超简单,让网页飞出浏览器

web
Document Picture-in-Picture 介绍     今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏 🎬 视频流媒...
继续阅读 »

Document Picture-in-Picture 介绍


    今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏


🎬 视频流媒体的画中画功能


        你可能已经在视频平台(如腾讯视频哔哩哔哩等网页)见过这种效果:视频播放时,可以点击画中画后。无论你切换页面,它都始终显示在屏幕的最上层,非常适合上班偷偷看电视💻


pip.gif


        在今天的教程中,不仅仅是视频,我将教你如何将任何 HTML 内容放入画中画模式,无论是动态内容、文本、图片,还是纯炫酷的 div,统统都能“飞”起来。✨


        一个如此有趣的功能,在网上却很少有详细的教程来介绍这个功能的使用。于是我决定写一篇详细的教程来教大家如何实现画中画 (建议收藏)😁


体验网址:Treasure-Navigationpip08.gif


github地址




📖 Document Picture-in-Picture 详细教程


🛠 HTML 基本代码结构


    首先,我们随便写一个简单的 HTML 页面,后续的 JS 和样式都会基于它实现。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>

<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>
<script>
// 在这里写你的 JavaScript 代码
</script>
</body>
</html>



1️. 请求 PiP 窗口


    PiP 的核心方法是 window.documentPictureInPicture.requestWindow。它是一个 异步方法,返回一个新创建的 window 对象。

    PIP 窗口可以将其看作一个新的网页,但它始终悬浮在屏幕上方。


document.getElementById("clickBtn").addEventListener("click", async function () {
// 获取将要放入 PiP 窗口的 DOM 元素
const pipContent = document.getElementById("pipContent");
// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent);
});

演示:


pip01.gif


👏 现在,我们已经成功创建了一个画中画窗口!
这段代码展示了如何将网页中的元素放入一个新的画中画窗口,并让它悬浮在最上面。非常简单吧


关闭PIP窗口


可以直接点右上角关闭PIP窗口,如果我们想在代码中实现关闭,直接调用window上的api就可以了


window.documentPictureInPicture.window.close();



2️. 检查是否支持 PiP 功能


    一切不能兼容浏览器的功能介绍都是耍流氓,我们需要检查浏览器是否支持PIIP功能
实际就是检查documentPictureInPicture属性是否存在于window上 🔧


if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

    如果是只需要将视频实现画中画功能,视频画中画 (Picture-in-Picture) 的兼容性会好一点,但是它只能将<video>元素放入画中画窗口。它与本文介绍的 文档画中画(Document Picture-in-Picture) 使用方法也是十分相似的。


image.png


image.png




3️. 设置 PiP 样式


    我们会发现刚刚创建的画中画没有样式,一点都不美观。那是因为我们只放入了dom元素,没有添加css样式。


3.1. 全局样式同步


假设网页中的所有样式如下:


<head>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
<link rel="stylesheet" type="text/css" href="https://abc.css">
</head>

为了方便,我们可以直接把之前的网页的css样式全部赋值给画中画


// 1. document.styleSheets获取所有的css样式信息
[...document.styleSheets].forEach((styleSheet) => {
try {
// 转成字符串方便赋值
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
// 创建style标签
const style = document.createElement('style');
// 设置为之前页面中的css信息
style.textContent = cssRules;
console.log('style', style);
// 把style标签放到画中画的<head><head/>标签中
pipWindow.document.head.appendChild(style);
} catch (e) {
// 通过 link 引入样式,如果有跨域,访问styleSheet.cssRules时会报错。没有跨域则不会报错
const link = document.createElement('link');
/**
* rel = stylesheet 导入样式表
* type: 对应的格式
* media: 媒体查询(如 screen and (max-width: 600px))
* href: 外部样式表的 URL
*/

link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
console.log('error: link', link);
pipWindow.document.head.appendChild(link);
}
});

演示:

image.png




3.2. 使用 link 引入外部 CSS 文件


向其他普通html文件一样,可以通过link标签引入特定css文件:


创建 pip.css 文件:


#pipContent {
width: 600px;
height: 300px;
background: skyblue;
}

js引用:


// 其他不变
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './pip.css'; // 引入外部 CSS 文件
pipWindow.document.head.appendChild(link);
pipWindow.document.body.appendChild(pipContent);

演示:

pip02.gif


3.3. 媒体查询的支持


可以设置媒体查询 @media (display-mode: picture-in-picture)。在普通页面中会自动忽略样式,在画中画模式会自动渲染样式


<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}

<!-- 普通网页中会忽略 -->
@media (display-mode: picture-in-picture) {
#pipContent {
background: lightgreen;
}
}
</style>

在普通页面中显示为粉色,在画中画自动变为浅绿色


演示:

pip03.gif




4️. 监听进入和退出 PiP 模式的事件


我们还可以为 PiP 窗口 添加事件监听,监控画中画模式的 进入退出。这样,你就可以在用户操作时,做出相应的反馈,比如显示提示或执行其他操作。


// 进入 PIP 事件
documentPictureInPicture.addEventListener("enter", (event) => {
console.log("已进入 PIP 窗口");
});

const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 退出 PIP 事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

演示


pip04.gif




5️. 监听 PiP 焦点和失焦事件


const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});

演示


pip05.gif




6. 克隆节点画中画


我们会发现我们把原始元素传入到PIP窗口后,原来窗口中的元素就不见了。

我们可以把原始元素克隆后再传入给PIP窗口,这样原始窗口中的元素就不会消失了


const pipContent = document.getElementById("pipContent");
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
// 核心代码:pipContent.cloneNode(true)
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

演示


pip07.gif


PIP 完整示例代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Picture-in-Picture API 示例</title>
<style>
#pipContent {
width: 600px;
height: 300px;
background: pink;
font-size: 20px;
}
</style>
</head>
<body>
<div id="container">
<div id="pipContent">这是一个将要放入画中画的 div 元素!</div>
<button id="clickBtn">切换画中画</button>
</div>

<script>
// 检查是否支持 PiP 功能
if ('documentPictureInPicture' in window) {
console.log("🚀 浏览器支持 PiP 功能!");
} else {
console.warn("⚠️ 当前浏览器不支持 PiP 功能,更新浏览器或者换台电脑吧!");
}

// 请求 PiP 窗口
document.getElementById("clickBtn").addEventListener("click", async function () {
const pipContent = document.getElementById("pipContent");

// 请求创建一个 PiP 窗口
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200, // 设置窗口的宽度
height: 300 // 设置窗口的高度
});

// 将原始元素克隆并添加到 PiP 窗口中
pipWindow.document.body.appendChild(pipContent.cloneNode(true));

// 设置 PiP 样式同步
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media;
link.href = styleSheet.href ?? '';
pipWindow.document.head.appendChild(link);
}
});

// 监听进入和退出 PiP 模式的事件
pipWindow.addEventListener("pagehide", (event) => {
console.log("已退出 PIP 窗口");
});

pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});

pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
});

// 关闭 PiP 窗口
// pipWindow.close(); // 可以手动调用关闭窗口
</script>
</body>
</html>




总结


🎉 你现在已经掌握了如何使用 Document Picture-in-Picture API 来悬浮任意 HTML 内容!
希望能带来更灵活的交互体验。✨


如果你有什么问题,或者对 PiP 功能有更多的想法,欢迎在评论区与我讨论!👇📬


作者:前端金熊
来源:juejin.cn/post/7441954981342036006
收起阅读 »

还在每次都写判断?试试惰性函数,让你的代码更聪明!

web
什么是惰性函数? 先来看个例子 function addEvent(el, type, handler) { if (window.addEventListener) { el.addEventListener(type, handler, fal...
继续阅读 »

什么是惰性函数?


先来看个例子


function addEvent(el, type, handler) {
if (window.addEventListener) {
el.addEventListener(type, handler, false);
} else {
el.attachEvent('on' + type, handler);
}
}

上面这段代码中,每次调用 addEvent 都会进行一遍判断。实际上,我们并不需要每次都进行判断,只需要执行一次就够了,当然,我们也可以存个全局的flag来记录,但是,有更好的办法了


function addEvent(el, type, handler) {
if (window.addEventListener) {
addEvent = function(el, type, handler) {
el.addEventListener(type, handler, false);
}
} else {
addEvent = function(el, type, handler) {
el.attachEvent('on' + type, handler);
}
}
return addEvent(el, type, handler); // 调用新的函数
}

这就是惰性函数:第一次执行时会根据条件覆盖自身,后续调用直接执行更新后的逻辑


惰性函数的实现方式


惰性函数一般情况下有两种实现方式


在函数内部重写自身


这种实现方式就是上面我们介绍的那样


function foo() {
console.log('初始化...');
foo = function() {
console.log('后续逻辑');
}
}

大多数情况下,这种实现方式都可以覆盖


用函数表达式赋值


const foo = (function() {
if (someCondition) {
return function() { console.log('A'); }
} else {
return function() { console.log('B'); }
}
})();

这种情况适用于模块或者立即执行的情况,其实就是用闭包做了个封装


惰性函数的应用场景


兼容性判断


我们在做适配的时候,很多时候需要进行浏览器特性的判断,比如之前提到的事件绑定


性能优化


其实惰性函数说起来很像单例,他的原理就是只执行一次,那么如果想要避免一些重复操作,尤其是重复初始化,就可以想一下是不是可以用惰性函数来处理,比如Canvas渲染引擎,加载某些外部依赖、判断登录状态等等


注意事项



  1. 写好注释,一定要写好注释,因为函数在执行后会变化,不写注释如果除了一些问题,可能后面维护的人会骂街,会大大增加你的不可替代性,咳咳,千万不要这么操作,一定要写好注释

  2. 不适合频繁修改逻辑和复杂上下文的场景,会增加复杂度


一句话总结:能判断一次就不要判断两次,惰性函数让你的代码更聪明


作者:那小孩儿
来源:juejin.cn/post/7490850417976508428
收起阅读 »

Electron 应用太重?试试 PakePlus 轻装上阵

web
Electron 作为将 Web 技术带入桌面应用领域的先驱框架,让无数开发者能够使用熟悉的 HTML、CSS 和 JavaScript 构建跨平台应用。然而,随着应用规模的扩大,Electron 应用的性能问题逐渐显现——内存占用高、启动速度慢、安装包体积庞...
继续阅读 »


Electron 作为将 Web 技术带入桌面应用领域的先驱框架,让无数开发者能够使用熟悉的 HTML、CSS 和 JavaScript 构建跨平台应用。然而,随着应用规模的扩大,Electron 应用的性能问题逐渐显现——内存占用高、启动速度慢、安装包体积庞大,这些都成为了用户体验的绊脚石。不过,现在有了 PakePlus,这些烦恼都将迎刃而解。


PakePlus官网文档:PakePlus


PakePlus开源地址:github.com/Sjj1024/Pak…



首先要轻


以一款基于 Electron 的文档编辑应用为例,在使用 PakePlus 优化前,安装包大小达 200MB,启动时间超过 10 秒。但是使用PakePlus重新打包之后,安装包大小控制在5M左右,缩小了将近40倍!启动时间也做到了2秒以内!这就是PakePLus的魅力所在。


开发者反馈:"迁移过程出乎意料的顺利,大部分代码无需修改,性能提升却立竿见影。"




其次都是其次



  • 🚀 基于 Rust Tauri,PakePlus 比基于 JS 的框架更轻量、更快。

  • 📦 内置丰富功能包——支持快捷方式、沉浸式窗口、极简自定义。

  • 👻 PakePlus 只是一个极简的软件,用 Tauri 替代旧的打包方式,支持跨平台桌面,将很快支持手机端。

  • 🤗 PakePlus 易于操作使用,只需一个 GitHub Token,即可获得桌面应用。

  • 🌹 不需要在本地安装任何复杂的依赖环境,使用 Github Action 云端自动打包。

  • 🧑‍🤝‍🧑 支持国际化,对全球用户都非常友好,并且会自动跟随你的电脑系统语言。

  • 💡 支持自定义 js 注入。你可以编写自己的 js 代码注入到页面中。

  • 🎨 ui 界面更美观更友好对新手更实用,使用更舒适,支持中文名称打包。

  • 📡 支持网页端直接使用,但是客户端功能更强大,更推荐客户端。

  • 🔐 数据安全,你的 token 仅保存在你本地,不会上传服务器,你的项目也都在你自己的 git 中安全存储。

  • 🍀 支持静态文件打包,将 Vue/React 等项目编译后的 dist 目录或者 index.html 丢进来即可成为客户端,何必是网站。

  • 🐞 支持 debug 调试模式,无论是预览阶段还是发布阶段,都可以找到 bug 并消灭 bug



使用场景


你有一个网站,想把它立刻变成跨平台桌面应用和手机APP,立刻高大尚。

你有一个 Vue/React 等项目,不想购买服务器,想把它打包成桌面应用。

你的 Cocos 游戏是不是想要跨平台客户端运行?完全没有问题。

你的 Unity 项目是不是想要跨平台打包为客户端?也完全没有问题。

隐藏你的网站地址,不被随意传播和使用,防止爬虫程序获取你的网站内容。

公司内网平台,不想让别人知道你的网站地址,只允许通过你的客户端访问。

想把某个网站变成自己的客户端,实现自定义功能,比如注入 js 实现自动化操作。

网站广告太多?想把它隐藏起来,用无所不能的 js 来屏蔽它们吧。

需要使用 tauri2 打包,但是依赖环境太复杂,本地电脑硬盘不够用,就用 PakePlus



热门包



PakePLus 支持 arm 和 inter 架构的安装包,流行的程序安装包仅仅包含了 mac 的 arm(M 芯片)版本 和 windows 的 Inter(x64)版本 和 Linux 的 x64 版本,如果需要更多架构的安装包,请使用 PakePlus 单独编译自己需要的安装包。热门包的下载地址请到官方文档下载体验



常见问题


mac提示:应用已随坏



这是因为没有给苹果给钱,所以苹果会拒绝你的应用。


解决办法:


Mac 用户可能在安装时看到“应用已损坏”的警告。 请点击“取消”,然后运行以下命令,输入电脑密码后,再重新打开应用:(这是由于应用需要官方签名,才能避免安装后弹出“应用已损坏”的提示,但官方签名的费用每年 99 美元...因此,需要手动绕过签名以正常使用)


sudo xattr -r -d com.apple.quarantine /Applications/PakePlus.app

 当你打包应用时,Mac 用户可能在安装时看到“应用已损坏”的警告。 请点击“取消”,然后运行以下命令,再重新打开应用:


sudo xattr -r -d com.apple.quarantine /Applications/你的软件名称.app



作者:1024小神
来源:juejin.cn/post/7490876486292389914
收起阅读 »

只需一行代码,任意网页秒变可编辑!

web
大家好,我是石小石! 在我们日常工作中,可能会遇到截图页面的场景,有时页面有些内容不符合要求,我们可能需要进行一些数值或内容的修改。如果你会PS,修改内容难度不高,如果你是前端,打开控制台也能通过修改dom的方式进行简单的文字修改。 今天,我就来分享一个冷门...
继续阅读 »

大家好,我是石小石!




在我们日常工作中,可能会遇到截图页面的场景,有时页面有些内容不符合要求,我们可能需要进行一些数值或内容的修改。如果你会PS,修改内容难度不高,如果你是前端,打开控制台也能通过修改dom的方式进行简单的文字修改。


今天,我就来分享一个冷门又实用的前端技巧 —— 只需一行 JavaScript 代码,让任何网页瞬间变成可编辑的! 先看看效果:


甚至,还可以插入图片等媒体内容



如何实现


很难想象,这么炫酷的功能,居然只需要在控制台输入一条指令:


document.designMode = "on";

打开浏览器控制台(F12),复制粘贴这行代码,回车即可。



如果你想关闭此功能,输入document.designMode = "off"即可。


Document:designMode 属性


MDN是这样介绍的:


document.designMode 控制整个文档是否可编辑。有效值为 "on""off"。根据规范,该属性默认为 "off"。Firefox 遵循这一标准。早期版本的 Chrome 和 IE 默认为 "inherit"。从 Chrome 43 开始,默认为 "off" 并不再支持 "inherit"。在 IE6-10 中,该值为大写。


兼容性方面,基本上所有浏览器都是支持的。



借助次API,我们也能实现Iframe嵌套页面的编辑:


iframeNode.contentDocument.designMode = "on";

关联API


与designMode关联的API其实还有contentEditable和execCommand(已弃用,但部分浏览器还可以使用)。


contentEditabledesignMode功能类似,不过contentEditable可以使特定的 DOM 元素变为可编辑,而designMode只能使整个文档可编辑。


特性contentEditabledocument.designMode
作用范围可以使单个元素可编辑可以使整个文档可编辑
启用方式设置属性为 truefalse设置 document.designMode = "on"
适用场景用于指定某些元素,如 <div>, <span>用于让整个页面变为可编辑
兼容性现代浏览器都支持现代浏览器都支持,部分老旧浏览器可能不支持

document.execCommand() 方法允许我们在网页中对内容进行格式化、编辑或操作。它主要用于操作网页上的可编辑内容(如 <textarea> 或通过设置 contentEditabledesignMode 属性为 "true" 的元素),例如加粗文本、插入链接、调整字体样式等。由于它已经被W3C弃用,所以本文也不再介绍了。


作者:石小石Orz
来源:juejin.cn/post/7491188995164897320
收起阅读 »

老板花一万大洋让我写的艺术工作室官网?! HeroSection 再度来袭!(Three.js)

web
引言.我不是鸽子大王!! 哈喽大家好!距离我上次发文已经过去半个月了,差点又变回了那只熟悉的“老鸽子”。不行,我不能堕落!我还没有将 Web3D 推广到普罗大众,还没有让更多人感受到三维图形的魅力 (其实是还没有捞着米)。怀着这样的心情,我决定重新振作,继续为...
继续阅读 »

引言.我不是鸽子大王!!


哈喽大家好!距离我上次发文已经过去半个月了,差点又变回了那只熟悉的“老鸽子”。不行,我不能堕落!我还没有将 Web3D 推广到普罗大众,还没有让更多人感受到三维图形的魅力 (其实是还没有捞着米)。怀着这样的心情,我决定重新振作,继续为大家带来更多关于 Three.jsShader 的进阶内容。


0.前置条件


欢迎阅读本篇文章!在深入探讨 Three.jsShader (GLSL) 的进阶内容之前,确保您已经具备以下基础知识:



  1. Three.js 基础:您需要熟悉 Three.js 的基本概念和使用方法,包括场景(Scene)、相机(Camera)、渲染器(Renderer)、几何体(Geometry)、材质(Material)和网格(Mesh)等核心组件。如果您还不熟悉这些内容,建议先学习 Three.js 的入门教程。

  2. Shader 语法:本文涉及 GLSL(OpenGL Shading Language)的编写,因此您需要了解 GLSL 的基本语法,包括顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)的编写,以及如何在 Three.js 中使用自定义着色器。


1. Hero Section 概览



Hero Section 是网页设计中的一个术语,通常指页面顶部的一个大型横幅区域。但对于开发人员而言,这个概念可以更直观地理解为用户在访问网站的瞬间所感受到的视觉冲击,或者促使用户停留在该网站的关键原因因素



话说这天老何接到了一个私活


chat.png


起始钱不钱的无所谓!主要是想挑战一下自己(不是)。最后的成品如图所示 (互动方式为鼠标滑动 + 鼠标点击 GIF 压缩太多了内容了,实际要好看很多)。


01.gif


PC端在线预览地址: fluid-light.vercel.app


Debug调试界面: fluid-light.vercel.app/#debug


源码地址: github.com/hexianWeb/f…


2.基础场景搭建


首先我来为你解读一下这个场景里面有什么,他非常简单。只有一个可以接受光照影响的平面几何体以及数个点光源构成,仅此而已。


让我去掉后处理以及一些页面文本元素展示给你看


fluidLight04.gif


构建这样的一个基础场景不难。


2.1 构建平面几何体


让我们先来解决平面几何体


值得注意的是,为了让显示效果更好,我使用了正交相机并让平面覆盖整个视窗大小


    this.geometry = new THREE.PlaneGeometry(2 * 屏幕宽高比, 2);

然后构建相应的物理材质,可以去 polyhaven 下载一些自己喜欢的texture 并下载下来。


Snipaste_2025-03-05_14-26-01.png
根据右边的分类选择纹理大类细分,随后选择想要下载的纹理点击下载。


因为我们本质是需要 Displacement Texture置换贴图 & Normal Texture 法线贴图


所以不需要太在意这个纹理是作用在什么物件上面的


Snipaste_2025-03-05_14-30-09.png


随后将纹理导入后赋予材质相应的属性,并对部分参数进行调整。通常直接赋予displacementMapThreejs 中显示平面的凹凸会特别明显。所以记得通过


displacementScale来调整相应的大小。


    this.material = new THREE.MeshPhysicalMaterial({
color: '#121423',
metalness: 0.59,
roughness: 0.41,
displacementMap: 下载的纹理贴图,
displacementScale: 0.1,
normalMap: 下载的法线贴图,
normalScale: new THREE.Vector2(0.68, 0.75),
side: THREE.FrontSide
});

最后将物体加入场景即可


    this.mesh = new THREE.Mesh(this.geometry, this.material);
scene.add(this.mesh);

(tips:MeshStandardMaterialMeshPhysicalMaterial 适合需要真实感光照和复杂物理特性的场景,但性能消耗较高。如果您的电脑出现卡顿可以选择消耗较少性能的物理材质)


2.2 灯光加入战场


在本案例中,高级感的来源之一就是灯光的变换。如果您足够细心,可能会注意到一些更微妙的细节:场景中的灯光并不是简单地从 A Color 切换到 B Color,而是同时存在多个光源,并且它们的强度也在动态变化。这种设计使得场景的光影效果更加丰富和立体。


03.gif


如果场景中只有一个光源,效果可能会显得单调。而本案例中,灯光的变化呈现出一种层次感:中间是白色,周围还有一层类似年轮的光圈,最后逐渐扩散为纯色背景。这种效果的关键在于同一时间场景中存在多个点光源。虽然多个光源会显著增加性能消耗,但为了实现唯美的视觉效果,这是值得的。


让我们逐步分析灯光是如何实现的。


1. 封装创建点光源的函数


为了简化代码并提高复用性,我们可以先封装一个创建点光源的函数。这个函数会返回一个包含光源对象和目标颜色的对象。


  createPointLight(intensity) {
const light = new THREE.PointLight(
0xff_ff_ff,
intensity,
100,
Math.random() * 10
);
light.position.copy(this.lightPosition); //所有的光源都同步在一个位置
return {
object: light,
targetColor: new THREE.Color()
};
}

2. 生成多个点光源


接下来,我们可以调用这个函数生成多个点光源,并将它们添加到场景中。


this.colors = [
new THREE.Color('orange'),
new THREE.Color('red'),
new THREE.Color('red'),
new THREE.Color('orange'),
new THREE.Color('lightblue'),
new THREE.Color('green'),
new THREE.Color('blue'),
new THREE.Color('blue')
];
this.lights = [
this.createPointLight(2),
this.createPointLight(3),
this.createPointLight(2.5),
this.createPointLight(10),
this.createPointLight(2),
this.createPointLight(3),
];

// 初始化灯光颜色
const numberLights = this.lights.length;
for (let index = 0; index < numberLights; index++) {
const colorIndex = Math.min(index, this.colors.length - 1);
this.lights[index].object.color.copy(this.colors[colorIndex]);
}

for (const light of this.lights) this.scene.add(light.object);

3. 动态调整光源强度


在场景中,所有光源同时存在,但它们的强度会有所不同。每次由光照强度为 10 的光源担任场景的主色。当用户点击场景时,灯光会像上楼梯或者传送带一样逐步切换,即由新的点光源担任场景主色。


aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
8 8"b, "Ya
8 8 "b, "Ya
8 aa=D光源=a8, "b, "Ya
8 8"b, "Ya "8""""""8
8 8 "
b, "Ya 8 8
8 a=C光源=8, "
b, "Ya8 8
8 8"
b, "Ya "8""""""" 8
8 8 "
b, "Ya 8 8
8 a=B光源=8, "
b, "Ya8 8
8 8"
b, "Ya "8""""""" 8
8 8 "
b, "Ya 8 8
8=A光源=, "
b, "Ya8 8
8"
b, "Ya "8""""""" 8
8 "
b, "Ya 8 8
8, "
b, "Ya8 8
"
Ya "8""""""" 8
"Ya 8 8
"
Ya8 8
"""""""""""""""""""""""""""""""""""""

让我们看看代码是如何实现的吧


    window.addEventListener('click', () => {
// 打乱颜色数组(看个人喜好)
this.colors = [...this.colors.sort(() => Math.random() - 0.5)];
// 标记开始颜色过渡
this.colorTransition = true;

// 为每个灯光设置目标颜色
const numberLights = this.lights.length;
for (let index = 0; index < numberLights; index++) {
const colorIndex = Math.min(index, this.colors.length - 1);
this.lights[index].targetColor = this.colors[colorIndex].clone();
}
});

然后再Render函数中以easeing方式更新颜色


  update() {
// 只在需要时更新颜色
if (this.colorTransition) {
const numberLights = this.lights.length;
const baseSmooth = 0.25;
const smoothIncrement = 0.05;

let allTransitioned = true; // 检查所有颜色是否已完成过渡

for (let index = 0; index < numberLights; index++) {
const smoothTime = baseSmooth + index * smoothIncrement;

// 使用目标颜色进行平滑过渡
const currentColor = this.lights[index].object.color;
const targetColor = this.lights[index].targetColor;

this.dampC(currentColor, targetColor, smoothTime, delta);

// 检查是否还在过渡
if (!this.isColorClose(currentColor, targetColor)) {
allTransitioned = false;
}
}

// 如果所有颜色都已完成过渡,停止更新
if (allTransitioned) {
this.colorTransition = false;
}
}
}

03.gif


3.后处理完善场景


在完成了场景的基本构建之后,我们已经实现了大约 80% 的内容。即使现在加上 UI,效果也不会太差。不过,为了让场景更具视觉冲击力和艺术感,我们可以通过后处理(Post Processing)技术来进一步提升质感。


04.gif


使用 UnrealBloomPassFilmPass


在本文中,我们将使用 UnrealBloomPass(辉光效果)和 FilmPass(电影滤镜)来增强场景的视觉效果。以下是具体的实现步骤:



  1. 引入后处理库:首先,我们需要引入 Three.js 的后处理库 EffectComposer 以及相关的 Pass 类。

  2. 创建 EffectComposerEffectComposer 是后处理的核心类,用于管理和执行各种后处理效果。

  3. 添加 RenderPassRenderPass 用于将场景渲染到后处理管道中。

  4. 添加 UnrealBloomPassUnrealBloomPass 用于实现辉光效果,可以使场景中的亮部区域产生光晕。

  5. 添加 FilmPassFilmPass 用于模拟电影胶片的效果,增加颗粒感和复古风格。


这里的具体参数需要看个人品味进行调试。同款参数可以从这里看我的源码。具体路径位于src\js\world\effect.js


    this.composer = new EffectComposer(this.renderer);
this.composer.addPass(this.renderPass);
this.composer.addPass(this.bloomPass);
this.composer.addPass(this.filmPass);

此时页面的质感是不是一下就上来了呢?


05.gif


最后我们需要添加最关键的一部,就是画面扭曲。


这里我们需要用到 ThreejsShaderPass,让我们来创建一个初始的ShaderPass,仅将 EffectComposer 的读取缓冲区的图像内容复制到其写入缓冲区,而不应用任何效果。


具体内容你可以从 Threejs 后处理中了解到更多


import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';

const BaseShader = {

name: 'BaseShader',

uniforms: {

'tDiffuse': { value: null },
'opacity': { value: 1.0 }

},

vertexShader: /* glsl */`

varying vec2 vUv;

void main() {

vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );

}`
,

fragmentShader: /* glsl */`

uniform float opacity;

uniform sampler2D tDiffuse;

varying vec2 vUv;

void main() {

vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;


}`


};

const BasePass = new ShaderPass( BaseShader );

此时画面不会有任何变化


让我们对uv进行简单操纵,让其读取tDiffuse时可以发生扭曲


      vec2 uv = vUv;
uv.y += sin(uv.x * frequency + offset) * amplitude;
gl_FragColor = texture2D(tDiffuse, uv);

最后得到效果


06.gif


4.最后一些话


技术的未来与前端迁移


随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 3D 技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 3D generation 技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。


为什么选择 Three.js


Three.js 作为最流行的 WebGL 库之一,不仅简化了三维图形的开发流程,还提供了丰富的功能和强大的扩展性。无论是创建复杂的 3D 场景,还是实现炫酷的视觉效果,Three.js 都能帮助开发者快速实现目标。


本专栏的愿景


本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。


加入社区,共同成长


如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 ice 图形学社区。这里是国内 Web 图形学最全的知识库,致力于打造一个全新的图形学生态体系!您可以在认证达人里找到我这个 Threejs 爱好者和其他大佬。


此外,如果您很喜欢 Threejs 又在烦恼其原生开发的繁琐,那么我诚邀您尝试 TresjsTvTjs, 他们都是基于 VueThreejs 框架。 TvTjs 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!


5.下期预告


未来科技?机器人概念官网来袭 !!!


08.gif


6. 往期回顾


2025 年了,我不允许有前端不会用 Trae 让页面 Hero Section 变得高级!!!(Threejs)


2024年了,前端人是时候给予页面一点 Hero Section 魔法了!!! (Three.js)


2023 年了,还有前端人不知道 commit 规范 ?


作者:何贤
来源:juejin.cn/post/7478403990141796352
收起阅读 »

前端如何优雅通知用户刷新页面?

web
前言老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀窝囊废:让用户刷新一下页面,或者清一下缓存老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?窝囊废:可以解决(OS:一点改的必要没有,用户...
继续阅读 »

前言

老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)

产品介绍

c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。

思考问题为什么产生

项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control,按正常前端重新部署后, 用户重新访问系统,已经是最新的页面。

但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。

产生问题

  • 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
  • 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
  • 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。

解决方案

  1. 前后端配合解决
  • WebSocket
  • SSE(Server-Send-Event)
  1. 纯前端方案 以下示例均以vite+vue3为例;
  • 轮询html Etag/Last-Modified

在App.vue中添加如下代码

const oldHtmlEtag = ref();
const timer = ref();
const getHtmlEtag = async () => {
const { protocol, host } = window.location;
const res = await fetch(`${protocol}//${host}`, {
headers: {
"Cache-Control": "no-cache",
},
});
return res.headers.get("Etag");
};

oldHtmlEtag.value = await getHtmlEtag();
clearInterval(timer.value);
timer.value = setInterval(async () => {
const newHtmlEtag = await getHtmlEtag();
console.log("---new---", newHtmlEtag);
if (newHtmlEtag !== oldHtmlEtag.value) {
Modal.destroyAll();
Modal.confirm({
title: "检测到新版本,是否更新?",
content: "新版本内容:",
okText: "更新",
cancelText: "取消",
onOk: () => {
window.location.reload();
},
});
}
}, 30000);
  • versionData.json

自定义plugin,项目根目录创建/plugins/vitePluginCheckVersion.ts

import path from "path";
import fs from "fs";
export function checkVersion(version: string) {
return {
name: "vite-plugin-check-version",
buildStart() {
const now = new Date().getTime();
const version = {
version: now,
};
const versionPath = path.join(__dirname, "../public/versionData.json");
fs.writeFileSync(versionPath, JSON.stringify(version), "utf8", (err) => {
if (err) {
console.log("写入失败");
} else {
console.log("写入成功");
}
});
},
};
}

在vite.config.ts中引入插件

import { checkVersion } from "./plugins/vitePluginCheckVersion";
plugins: [
vue(),
checkVersion(),
]

在App.vue中添加如下代码

const timer = ref()
const checkUpdate = async () => {
let res = await fetch('/versionData.json', {
headers: {
'Cache-Control': 'no-cache',
},
}).then((r) => r.json())
if (!localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
} else {
if (res.version !== localStorage.getItem('demo_version')) {
localStorage.setItem('demo_version', res.version)
Modal.confirm({
title: '检测到新版本,是否更新?',
content: '新版本内容:' + res.content,
okText: '更新',
cancelText: '取消',
onOk: () => {
window.location.reload()
},
})
}
}
}

onMounted(()=>{
clearInterval(timer.value)
timer.value = setInterval(async () => {
checkUpdate()
}, 30000)
})

Use

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { webUpdateNotice } from '@plugin-web-update-notification/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
webUpdateNotice({
logVersion: true,
}),
]
})

作者:李暖阳啊
来源:juejin.cn/post/7439905609312403483

收起阅读 »

部署项目,console.log为什么要去掉?

web
console.log的弊端 1. 影响性能(轻微但可优化) console.log 会占用 内存 和 CPU 资源,尤其是在循环或高频触发的地方(如 mousemove 事件)。 虽然现代浏览器优化了 console,但大量日志仍可能导致 轻微性能下降。 2...
继续阅读 »

console.log的弊端


1. 影响性能(轻微但可优化)


console.log 会占用 内存 和 CPU 资源,尤其是在循环或高频触发的地方(如 mousemove 事件)。
虽然现代浏览器优化了 console,但大量日志仍可能导致 轻微性能下降


2. 暴露敏感信息(安全风险)


可能会 泄露 API 接口、Token、用户数据 等敏感信息,容易被恶意利用。


3. 干扰调试(影响开发者体验)


生产环境日志过多,可能会 掩盖真正的错误信息,增加调试难度。
开发者可能会误以为某些 console.log 是 预期行为,而忽略真正的 Bug。


4. 增加代码体积(影响加载速度)


即使 console.log 本身很小,但 大量日志 会增加打包后的文件体积,影响 首屏加载速度


解决方案:移除生产环境的 console.log


1. 使用 Babel 插件


在 babel.config.js 中配置:


module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
]
,
plugins: [
['@babel/plugin-proposal-optional-chaining']
,
...process.env.NODE_ENV === 'production' ? [['transform-remove-console', { exclude: ['info', 'error', 'warn'] }]] : []
]
}

特点



  • 不影响源码,仅在生产环境生效,开发环境保留完整 console

  • 配置简单直接,适合快速实现基本需求。

  • 依赖 Babel 插件


2. 使用 Terser 压缩时移除(Webpack/Vite 默认支持)


在 vite.config.js 或 webpack.config.js 中配置:


module.exports = {
chainWebpack: (config) => {
config.optimization.minimizer("terser").tap((args) => {
args[0].terserOptions.compress = {
...args[0].terserOptions.compress,
drop_console: true, // 移除所有 console
pure_funcs: ["console.log"], // 只移除 console.log,保留其他
};
return args;
});
},
};

特点



  • 不影响源码,仅在生产环境生效,开发环境保留完整 console

  • 避免 Babel 插件兼容性问题

  • 需要额外配置


3. 自定义 console 包装函数(按需控制)


// utils/logger.js
const logger = {
log: (...args) => {
if (process.env.NODE_ENV !== "production") {
console.log("[LOG]", ...args);
}
},
warn: (...args) => {
console.warn("[WARN]", ...args);
},
error: (...args) => {
console.error("[ERROR]", ...args);
},
};

export default logger;

使用方式


import logger from "./utils/logger";

logger.log("Debug info"); // 生产环境自动不打印
logger.error("Critical error"); // 始终打印

特点



  • 可以精细控制日志,可控性强,可以自定义日志级别。

  • 不影响 console.warn 和 console.error

  • 需要手动替换 console.log


作者:用户2612458340161
来源:juejin.cn/post/7485938326336766003
收起阅读 »

因网速太慢我把20M+的字体压缩到了几KB

web
于水增 故事背景 事情起源于之前做的海报编辑器,自己调试时无意中发现字体渲染好慢,第一反应就是网怎么变慢了,断网了?仔细一看才发现,淦!这几个字体资源咋这么大,难怪网速变慢了呢😁😁。 图片中的海报包含6种字体,其中最大的字体文件超过20M,而最长的网络加载...
继续阅读 »

于水增



故事背景


事情起源于之前做的海报编辑器,自己调试时无意中发现字体渲染好慢,第一反应就是网怎么变慢了,断网了?仔细一看才发现,淦!这几个字体资源咋这么大,难怪网速变慢了呢😁😁。



图片中的海报包含6种字体,其中最大的字体文件超过20M,而最长的网络加载时长已接近20s。所以海报实际效果图展示耗时太久,很影响用户体验。那就趁此机会跟大家聊聊 字体 这件小事。


字体文件为什么那么大?


🙋 DeepSeek同学来回答下大家:


这里所说的大体积的字体资源多数是指中文主要原因下边两点



  • 中文字符数量庞大,英文仅 26 个字母 + 符号,中文(全字符集)包含 70,000+ 字符

  • 字形结构复杂,字体文件需为每个字符存储独立的矢量轮廓数据,而汉字笔画复杂,每个字符需存储数百个控制点坐标(例如「龍」字的轮廓点数量可能是「A」的 10 倍以上)


总结下来就是咱们不光汉字多,书法也是五花八门,它是真小不了。如果你硬要压缩,我们只能从第一点入手,将字符数量进行缩减,比如保留 1000 个常用汉字。


web网站中常见字体格式



由于我司物料部门提供的为TTF格式,所以这里通过 思源黑体 给一个直观的对比:



  • TTF 文件:16.9 MB

  • WOFF2 文件:7.4 MB(压缩率约 60%)


两者为什么会差这么多,其实WOFF2 只是在 TTF/OTF 基础上添加了压缩和 Web 专用元数据,且WOFF2支持增量解码,也就是边下载边解析,文本可更快显示(即使字体未完全加载,不过有待考证)。


TTF有办法优化吗?


回归问题本身


首先来简单回顾下我们自定义的字体是如何在浏览器中完成渲染的


一般情况下我们对字体文件的引用方式为下边三种



  • 通过绝对路径来引用,这种就是将字体文件打包在工程内,所以带来的结果就是工程打包文件体积太大


@font-face {
font-family: 'xxx';
src: url('../../assets/fonts.woff2')
}


  • 第二种就是 CDN 中存放的字体文件,一般是通过这种方式来减少工程的编译后体积


@font-face {
font-family: 'xxx';
src: url('https://xxx.woff2')
}


  • 通过 FontFace 构造一个字体对象


前两种一般是在浏览器构建 CSSOM 时,当遇到**<font style="color:rgba(0, 0, 0, 0.9);background-color:rgb(243, 243, 243);">url()</font>** 引用时会发起资源请求。第三种则是通过 js 来控制字体的加载流程,所以归根结底就是字体文件太大,导致网络资源下载速度慢,我们只能从优化字体大小的方向入手


确定解决方向


下面汇总下查到的具体几个优化方案,诸如提高网络传输效率,增加缓存之类的就不讲了,能够立竿见影的主要下边这两个方案


方案方法/原理适用场景
字体子集化通过工具将字体文件进行提取(支持动态),返回指定的字符集的字体文件,其根本就是减少单次资源请求的体积,需要服务端支持这个方案是所有优化场景的基础
按需加载通过设置 unicode-range 属性,浏览器在进行css样式计算时候,会根据页面中的字符与设置的字符范围进行比对,匹配上会加载对应的字体文件前提是资源已经被子集化,比较适用多语言切换的场景

简单来说,字体子集化可单独食用,按需加载则必须要将字体前置子集化。才能完美实现按需加载。就我的这个项目而言,动态子集化方案不要太完美,毕竟一张海报本身就没几个字儿!所以我们这次将抛弃 CDN,通过动态的将服务本地中的字体资源子集化来实现字体的压缩效果。



这里我们使用python中的一个字体工具库 fontTools 来实现一个动态子集化,类似于 Google Fonts 的实现。核心思路就是将字符传给服务端,通过工具将传入的字符在本地字体文件中提取并返回给客户端,通过fontTools 还可以将TTF格式转化为和Web更搭的WOFF2格式。实现细节如下述代码所示


@app.route('/font/<font_name>', methods=['GET'])
def get_font_subset(font_name):
# 获取本地字体文件路径
font_path = os.path.join(FONTS_DIR, f"{font_name}.ttf")
# 获取子集字符
chars = request.args.get('text', '')
# 字体文件格式
format = request.args.get('format', 'woff2').lower()

# 处理字符,去重
unique_chars = ''.join(sorted(set(chars)))
try:
# 配置子集化选项
options = Options()
options.flavor = format if format in {'woff', 'woff2'} else
options.desubroutinize = True # 增强兼容性
subsetter = Subsetter(options=options)

# 加载字体并生成子集
font = TTFont(font_path)
subsetter.populate(text=unique_chars)
subsetter.subset(font)

# 保存为指定格式
buffer = io.BytesIO()
font.save(buffer)
buffer.seek(0)

# 确定MIME类型
mime_type = {
'woff2': 'font/woff2',
'woff': 'font/woff',
}[format]

# 创建响应并设置
response = Response(buffer.read(), mimetype=mime_type)
# 其他设置...
return response

except Exception as e:
# 子集化失败...


前端代码中增加了一些字符提取的工作,我本身就是通过 FontFace Api 来请求字体资源的,所以我仅需将资源链接替换为子集化字体的接口就可以了,下面代码来描述字体的加载过程


// ...其他逻辑
Toast.loading('字体加载中')
// 遍历海报中的字体对象
[...new Set(fontFamilies)].forEach((fontName) => {
// 在字体库中找到对应字体详细信息
const obj = fontLibrary.find((el) => el?.value === fontName) ?? {};

if (obj.value && obj.src) {
// 处理海报中提取的文案集合
const text = textMap[obj.value].join('');
// 构建字体对象
const font = new FontFace(
obj.value,
`url(http://127.0.0.1:5000/font/${obj.value}?text=${text}&format=woff2)`
);
// 加载字体
font.load();
// 添加到文档字体集中
document.fonts.add(font);
}
});
// 文档所有字体加载完毕后返回成功的 Promise
return document.fonts.ready.finally(() => Toast.destory());

好了,刷新下浏览器,来看看最终的效果:



这这 真立竿见影(主要是基数大😁😁),最终得到的结果就是,实际 22.4M 的字体文件,子集化后缩减到 3.6KB。实际效果图生成的时间由 20s+ 缩减到毫秒级(300ms 以内)。这下就无惧网速了吧!


结语


总的来说,优化字体加载的方案有很多,我们需要结合自己的实际业务场景来进行选型,字体子集化确实是一种高效且实用的优化手段,更多的实践思路可以参考下 Google fonts


作者:古茗前端团队
来源:juejin.cn/post/7490337281866317836
收起阅读 »