跨平台开发的新纪元:Tauri 2.0 横空出世,移动端、桌面端一网打尽!
前言
Tauri 2.0 正式版终于在 2024 年 10 月 2 日正式发布了。这次重大更新不仅带来了令人兴奋的新特性,更是为跨平台应用开发开辟了一条全新的道路。让我们一起来看看这个重量级版本带来了哪些惊喜!
1.移动端支持:拥抱全平台时代
Tauri 2.0 最引人注目的特性莫过于对 iOS 和 Android 的全面支持。现在,您可以用同一套代码库开发桌面端(Windows、macOS、Linux)和移动端应用,真正实现"一次编写,到处运行"的梦想。这不仅大大提高了开发效率,还为您的应用打开了更广阔的市场。
2.插件系统升级:灵活性与可扩展性的完美结合
新版本中,Tauri 将大量核心功能转移到了插件中。这意味着您可以根据需求自由选择功能,让应用更加轻量化。同时,插件系统的改进也为社区贡献打开了大门,期待看到更多创新的插件涌现。Tauri 2.0 的插件系统不仅更加灵活,还提供了丰富的官方插件,满足各种开发需求。以下是部分官方插件及其功能:
- 自动启动 (Autostart): 让您的应用在系统启动时自动运行。
- 条形码扫描器 (Barcode Scanner): 在移动应用中使用相机扫描二维码和条形码。
- 生物识别 (Biometric): 在Android和iOS上进行生物识别认证。
- 剪贴板 (Clipboard): 读取和写入系统剪贴板。
- 命令行接口 (CLI): 解析命令行参数。
- 深度链接 (Deep Linking): 将您的Tauri应用设置为特定URL的默认处理程序。
- 对话框 (Dialog): 用于打开/保存文件和显示消息的原生系统对话框。
- 文件系统 (File System): 访问文件系统。
- 全局快捷键 (Global Shortcut): 注册全局快捷键。
- HTTP客户端: 使用Rust编写的HTTP客户端。
- 本地主机 (Localhost): 在生产应用中使用本地主机服务器。
- 日志 (Logging): 可配置的日志记录。
- NFC: 在Android和iOS上读写NFC标签。
- 通知 (Notifications): 向用户发送原生通知。
- 操作系统信息 (OS Information): 读取操作系统信息。
- 持久化作用域 (Persisted Scope): 在文件系统中持久化运行时作用域更改。
- 定位器 (Positioner): 将窗口移动到常用位置。
- 进程 (Process): 访问当前进程。
- Shell: 使用默认应用程序管理文件和URL,以及生成子进程。
- 单实例 (Single Instance): 确保Tauri应用同时只运行一个实例。
- SQL: 提供前端与SQL数据库通信的接口。
- 存储 (Store): 持久化的键值存储。
- Stronghold: 加密、安全的数据库。
- 系统托盘 (System Tray): 系统托盘功能。
- 更新器 (Updater): Tauri应用的应用内更新。
- 上传 (Upload): 通过HTTP进行文件上传。
- WebSocket: 在JavaScript中使用Rust客户端打开WebSocket连接。
- 窗口自定义 (Window Customization): 自定义窗口外观和行为。
- 窗口状态 (Window State): 保存窗口大小和位置。
这些插件涵盖了从基础功能到高级特性的广泛范围,让开发者能够根据项目需求灵活选择。通过这种模块化的方式,Tauri不仅保持了核心框架的轻量级,还为开发者提供了强大的扩展能力。无论您是开发一个简单的工具还是复杂的企业级应用,Tauri的插件系统都能满足您的需求。
3.安全性大幅提升:告别allowlist,迎接新的权限系统
Tauri 2.0 抛弃了旧的 allowlist 系统,引入了更加灵活和强大的权限、作用域和功能系统。这不仅提高了安全性,还让开发者能够更精细地控制应用的权限。值得一提的是,Tauri 还通过了独立的安全审计,让您使用起来更加放心。
4.性能优化:IPC层重写,更快更强
通过重写进程间通信(IPC)层,Tauri 2.0现在支持原始有效载荷,这意味着在前端和后端之间传输大量数据时,性能得到了显著提升。如果您的应用需要处理大量数据,这个特性绝对不容错过。
5.开发体验升级:HMR支持更给力
热模块替换(HMR)现在扩展到了移动设备和模拟器。这意味着您可以实时预览应用在不同设备上的表现,大大加速了开发和调试过程。
6.分发更简单:一站式解决方案
Tauri 2.0提供了详尽的分发指南,覆盖了从App Store到Google Play,再到Microsoft Store等多个平台。无论您的目标市场在哪里,Tauri都能帮您轻松应对。
结语
Tauri 2.0 的正式发布无疑是跨平台开发领域的一个重要里程碑。它不仅延续了 Tauri 一贯的轻量、快速的特点,还通过移动端支持、增强的插件系统和改进的安全机制等特性,为开发者提供了更强大、更灵活的工具。
如果您正在寻找一个能够同时覆盖桌面端和移动端的开发框架,Tauri 2.0绝对值得一试。它不仅能帮您节省时间和资源,还能为您的应用带来卓越的性能和安全性。
参考文章
来源:juejin.cn/post/7423231530498031631
跟 Antfu 一起学习 CSS 渐入动画
周末无事,翻阅 Antfu 的博客,发现一篇很有意思的文章,用简单的 CSS animation 动画实现博客文章按照段落渐入,效果如下:
是不是很有意思呢?作为一名前端开发,如果产品给你提出这样的动画需求,你能否实现出来呢?在继续阅读之前,不妨先独立思考一下,如何用 CSS 来完整这种动画。
PS:什么,你问 Antfu 是谁?他可是前端圈里面的偶像级人物:
Antfu 是 Anthony Fu 的昵称,他是一位知名的开源软件开发者,活跃于前端开发社区。Anthony Fu 以其对 Vue.js 生态系统的贡献而著名,包括但不限于 Vite、VueUse 等项目。Antfu 也因为他在 GitHub 上的活跃参与和贡献而受到许多开发者的尊敬和认可。
首先用 CSS 写一个渐入动画,相信这个大家都看得懂:
@keyframes enter {
0% {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
上述代码定义了一个名为 enter 的关键帧动画,其效果使得元素从透明度为0(完全透明)逐渐变为透明度为1(完全不透明),同时元素会在垂直方向上从 10px 以上的位置移动到最终位置。具体来说,关键帧如下:
0%
:动画的起始状态(动画开始时刻)。在这个状态中,元素的透明度opacity
设置为0,表示元素是完全透明的,看不见的。同时,transform: translateY(10px);
属性表示元素在垂直方向上被推移了10px
,即元素的起始位置是它最终位置的上方10px
。
to
或100%
:动画的结束状态(动画结束时刻)。在这个状态中,元素的透明度opacity
设置为1,表示元素完全不透明,完全可见。transform: none;
表示取消了之前的变换效果,元素恢复到它的原始形态和位置。
难道这样就行了吗?当然不行,如果仅仅对内容添加上述动画,效果是文章整体渐入,效果如下:
然而我们想要的效果是一段一段渐入呀,那怎么办呢?思路很简单:
给每个段落分别添加上述动画,然后按照先后顺序延迟播放动画。
[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}
上面的关键就是 animation-delay
这个属性,为了方便 HTML 编码,这里使用了 CSS 变量来进行控制,把元素的延迟时间总结到如下的公式里面:
calc(var(--stagger) * var(--delay) + var(--start));
其中变量的含义如下:
--stagger
是段落序号,值为1、2、3...--delay
是上下两个段落的延迟时间间隔--start
是初始延迟时间,即整片文章第一段的延迟偏移量
有了这些变量,就可以按照段落的前后顺序,写出如下 HTML 代码了:
<p style="--stagger: 1" data-animate>Block 1</p>
<p style="--stagger: 2" data-animate>Block 2</p>
<p style="--stagger: 3" data-animate>Block 3</p>
<p style="--stagger: 4" data-animate>Block 4</p>
<p style="--stagger: 5" data-animate>Block 5</p>
<p style="--stagger: 6" data-animate>Block 6</p>
<p style="--stagger: 7" data-animate>Block 7</p>
<p style="--stagger: 8" data-animate>Block 8</p>
实现的效果如下:
可以说相当棒了!但是这里还有个问题,就是 markdown 文章转成 HTML 的时候,不会总是 p
标签吧,也有可能是 div
和 pre
等其他标签,而且你还要手动给这些标签添加 --stagger
变量,这个简直不能忍啊。Antfu 最后给出的解决方案是这样的:
slide-enter-content > * {
--stagger: 0;
--delay: 150ms;
--start: 0ms;
animation: slide-enter 1s both 1;
animation-delay: calc(var(--start) + var(--stagger) * var(--delay));
}
.slide-enter-content > *:nth-child(1) { --stagger: 1; }
.slide-enter-content > *:nth-child(2) { --stagger: 2; }
.slide-enter-content > *:nth-child(3) { --stagger: 3; }
.slide-enter-content > *:nth-child(4) { --stagger: 4; }
.slide-enter-content > *:nth-child(5) { --stagger: 5; }
.slide-enter-content > *:nth-child(6) { --stagger: 6; }
.slide-enter-content > *:nth-child(7) { --stagger: 7; }
.slide-enter-content > *:nth-child(8) { --stagger: 8; }
.slide-enter-content > *:nth-child(9) { --stagger: 9; }
.slide-enter-content > *:nth-child(10) { --stagger: 10; }
.slide-enter-content > *:nth-child(11) { --stagger: 11; }
.slide-enter-content > *:nth-child(12) { --stagger: 12; }
.slide-enter-content > *:nth-child(13) { --stagger: 13; }
.slide-enter-content > *:nth-child(14) { --stagger: 14; }
.slide-enter-content > *:nth-child(15) { --stagger: 15; }
.slide-enter-content > *:nth-child(16) { --stagger: 16; }
.slide-enter-content > *:nth-child(17) { --stagger: 17; }
.slide-enter-content > *:nth-child(18) { --stagger: 18; }
.slide-enter-content > *:nth-child(19) { --stagger: 19; }
.slide-enter-content > *:nth-child(20) { --stagger: 20; }
只要给文章容器增加 slide-enter-content
样式,那么通过 nth-child()
就能为其直接子元素按照顺序设置 stagger
变量啦!
秒啊,实在是妙!不得不佩服大佬的脑洞,不过,杠精的你可能会说,我的文章又不止 20 个子元素,超过 20 怎么办呢?我说哥,你不会自己往后加嘛!
感兴趣的同学可以查看最终的样式代码,跟上述 demo 有一点点区别,相信你能从中学到不少东西,例如 Antfu 把 data-animate
属性关联的样式拆成了两段:
[data-animate] {
--stagger: 0;
--delay: 120ms;
--start: 0ms;
}
@media (prefers-reduced-motion: no-preference) {
[data-animate] {
animation: enter 0.6s both;
animation-delay: calc(var(--stagger) * var(--delay) + var(--start));
}
}
写前端这么多年,我是第一次见到 @media (prefers-reduced-motion: no-preference)
这个媒体查询的用法,一脸懵逼,赶紧恶补了一把才知道:
在 CSS 中,@media 规则用于包含针对不同媒体类型或设备条件的样式。
prefers-reduced-motion
是一个媒体查询的功能,该功能用于检测用户是否有减少动画和动态效果的偏好。一些用户可能对屏幕上的快速或复杂动作敏感,这可能会导致不适或干扰体验,因此他们在操作系统中设置了减少动画的选项。
因此,对于那些讨厌动画的用户,就不用展示这么花哨的效果,直接展示文章就行啦!
来源:juejin.cn/post/7338742634167205900
写了个自动化打包工具,大大滴解放了电脑性能
前段时间手底下的小伙伴跟我吐槽,说后端一点小改动就马上要包,电脑性能很差一旦run build
之后就得等好几分钟的空窗期,被迫摸鱼导致加班,我灵机一动,是不是可以利用服务器的性能,编写自动化构建从而实现让后端、测试点点点,就能得到他们想要的不同版本的包、或者不同分支的构建产物呢?
于是乎就有了我的设计并产出的开源:Sa-io https://github.com/LIAOJIANS/sa-io.git
Sa-io操作流程:新建项目(指定gitURL) => 内部执行(npm install)=> run build => SE(推送Sucesss日志) => publish(指定目标地址)=> dowl (下载专属产物)
项目架构
1、UI层
2、逻辑层
3、数据层
4、所需环境层
核心实现逻辑
1、技术清单
child_process
:创建子进程并执行构建脚本;chokidar
: 监听日志文件内容;scp2
:建立SSH连接并传输文件;Vue3
:UI界面采用VUE3 + TS
2、核心逻辑
Run Build
router.post('/build', [
(() =>
['shell', 'install', 'projectName'].map((fild) =>
body(fild)
.notEmpty()
.withMessage('username or token is null'),
))(),
], (req, res, next) => {
checkBeforRes(next, req, async () => {
const {
shell,
install,
removeNm,
shellContent,
branch,
projectName,
pull,
...onter
} = req.body
if (os.platform() !== 'linux' && shell) {
return new Result(null, 'Running shell scripts must be in a Linux environment!!!')
.fail(res)
}
const curTime = Date.now()
const id = `${projectName}-${curTime}`
const fileName = `${id}.log`
const logPath = path.resolve(__dirname, `../log/${fileName}`)
let status = 'success'
const getHistory = () => getFileContentByName('history', [])
// 生成构建历史
let data = [
...getHistory(),
{
id,
projectName,
buildTime: curTime,
status: '',
branch
}
]
// 生成日志文件
getFileContentByName(
'',
'',
logPath
)
// 写入history基本信息
setFileContentByName(
'history',
data,
true
)
if (removeNm) {
await rmDir(projectName, 'node_modules') // 删除node_modules 防止不同分支不同版本的依赖冲突
rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}
if (branch) { // 如果有分支,并且分支不能等于当前分支,否则切换分支并拉取最新
const projects = getFileContentByName('projects')
const project = projects.find(p => p.projectName === projectName)
if (project.branch !== branch) {
try {
if (install) {
rmFile(`${projectName}/package-lock.json`) // 删除安装依赖日志,防止版本缓存
}
await gitCheckoutPro(projectName, branch)
setFileContentByName('projects', [
...projects.map(p => {
if (p.projectName === projectName) {
p.branch = branch
}
return p
})
], true)
} catch (e) {
console.log(e)
setFileContentByName(
'history',
[
...data,
{
projectName,
buildTime: curTime,
status: 'error',
branch
}
],
true
)
res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}
} else if (pull) { // 拉取最新
try {
await gitPullPro(projectName, logPath)
} catch (e) {
res.status(500).send('checkout error!!! Please review the log output!!!!!!')
}
}
}
new Result(`${id}`, 'building, Please review the log output!!!!!!').success(res)
const compressedPro = () => {
status = 'success'
compressed(`${projectName}-${curTime}`, projectName)
console.log('success')
copyFile(
path.resolve(__dirname, `../project/${projectName}/dist`),
path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
)
const {
publish,
...left
} = onter
if (publish) {
publishTragetServer({
...left,
localPath: path.resolve(__dirname, `../builds/${projectName}-${curTime}`)
})
}
}
if (shell) { // 执行sh脚本
setFileContentByName(
projectName,
shellContent,
true,
path.resolve(__dirname, `../project/${projectName}/build.sh`)
)
await shellPro(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
} else { // 执行打包工作流
(
await (install ? installAfterBuildPro : buildPro)(projectName, logPath)
.then(compressedPro)
.catch(() => {
status = 'error'
console.log('error')
})
)
}
let newData = getHistory()
newData = newData.map(c => {
if (c.id === id) {
c.status = status
}
return c
})
setFileContentByName(
'history',
newData,
true
)
})
})
UI界面展示
最后放个项目地址:github.com/LIAOJIANS/s…
来源:juejin.cn/post/7445098587808514082
一个js库就把你的网页的底裤🩲都扒了——import-html-entry
概述
import-html-entry
是一个用于动态加载和处理 HTML 和 JS 文件的库,主要用于微前端架构中。它能够从远程服务器拉取 HTML 内容,并对其中的 JS 和 CSS 进行处理,以便在主应用中加载和执行。这个库是 qiankun
微前端框架的核心依赖之一,提供了强大的动态加载和执行能力。在微前端框架 qiankun
中,import-html-entry
被用来解决 JS Entry
的问题,通过 HTML Entry
的方式,让用户接入微应用就像使用 iframe
一样简单。
使用方法
安装
首先,你需要通过 npm 或 yarn 安装 import-html-entry
:
npm install import-html-entry
或者
yarn add import-html-entry
基本使用
以下是一个简单的示例,展示如何使用 import-html-entry
加载一个远程的 HTML 文件,
我们看官网的例子
在index.html中
使用import-html-entry加载./template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>index</title>
</head>
<body>
<script type="module">
window.onerror = e => {
console.log('error', e.message);
};
window.onunhandledrejection = (e) => {
console.log('unhandledrejection', e.reason.message);
};
import('./dist/index.js').then(({ importEntry }) => {
importEntry('./template.html').then(res => {
console.log(res);
return res.execScripts().then(exports => {
console.log(exports);
});
}).catch(e => {
console.log('importEntry failed', e.message);
});
});
</script>
</body>
</html>
template.html如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<link href="https://unpkg.com/antd@3.13.6/dist/antd.min.css" rel="stylesheet">
<link href="https://unpkg.com/bootstrap@4.3.1/dist/css/bootstrap-grid.min.css" rel="stylesheet">
</head>
<body>
<script src="./a.js"></script>
<script ignore>alert(1)</script>
<script src="./b.js"></script>
<script src="./c.js"></script>
<script src="https://unpkg.com/react@16.4.2/umd/react.production.min.js"></script>
<script src="https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js"></script>
<script src="https://www.baidu.com"></script>
</body>
</html>
template.html被import-html-entry处理过后如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>test</title>
<style>
/* antd样式被内链进入 */
</style>
<style>
/* bootstrap样式被内链进入 */
</style>
</head>
<body>
<!-- script http://127.0.0.1:7001/a.js replaced by import-html-entry -->
<!-- ignore asset js file replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/b.js replaced by import-html-entry -->
<!-- script http://127.0.0.1:7001/c.js replaced by import-html-entry -->
<!-- script https://unpkg.com/react@16.4.2/umd/react.production.min.js replaced by import-html-entry -->
<!-- script https://unpkg.com/mobx@5.0.3/lib/mobx.umd.js replaced by import-html-entry -->
<!-- script https://www.baidu.com/ replaced by import-html-entry -->
</body>
</html>
可以发现html中的css被处理成为内链样式的了,其中的js代码script被注释掉了
importHTML
返回值有如下几个:
1、template---处理过后的html
2、assetPublicPath---资源路径
3、getExternalScripts---执行后返回脚本信息
4、getExternalStyleSheets---执行后返回样式信息
5、execScripts---js代码执行器,可以传入代理的window对象
我们可以看出来,经过import-html-entry
处理后能够拿到这个html中的js、css内容,其中css会被处理成为内链样式嵌入HTML中,js我们可以通过execScripts传入自己的代理window可以实现js沙箱隔离
qiankun中如何使用的?
我们观察qiankun
源码中是如何使用的import-html-entry
的
在src/loader.js中如下:
// 266行
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 347行
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// get the lifecycle hooks from module exports
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
可以看到和预期一样
1、使用import-html-entry
拿到js执行器
2、执行execScripts,并且传入自己的globalContext
3、根据导出,拿到生命周期函数lifecycle
源码解析
import-html-entry
的核心功能是通过 fetch
获取指定 URL 的 HTML 内容,然后解析并处理这个 HTML 模板,最终返回一个包含处理后的 HTML、CSS 和 JS 的 Promise
对象。具体步骤如下:
- 拉取 HTML 并处理:通过
fetch
获取到 URL 对应的全部内容(即 HTML 文件的字符串),然后解析出以下内容:经过初步处理后的 HTML(去掉外链 CSS 和外链 JS)、由所有script
组成的数组、由所有style
组成的数组。 - 嵌入 CSS:通过
fetch
拉取到上述style
数组里面对应的 CSS,然后将拉取到的每一个 href 对应的 CSS 通过<style>
包裹起来且嵌入到 HTML 中。 - 执行 JS 脚本:支持执行页级 JS 脚本以及拉取上述 HTML 中所有的外联 JS 并支持执行。因此,在微前端中,使用此依赖可以直接获取到子应用(某 URL)对应的 HTML 且此 HTML 上已经嵌好了所有的 CSS,同时还可以直接执行子应用的所有 JS 脚本且此脚本还为 JS 隔离(避免污染全局)做了预处理。
整体流程如下图所示:
execScripts
code = getExecutableScript()
通过function+with实现js沙箱
function getExecutableScript(scriptSrc, scriptText, opts = {}) {
const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? (
scopedGlobalVariableDefinition
? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
: `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
)
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
evalCode(scriptSrc, code)
通过eval执行代码
export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
processTpl
const { template, scripts, entry, styles } = processTpl(getTemplate(html), assetPublicPath, postProcessTemplate);
看一下执行结果。
通过processTpl实现。
1、替换HTML
2、导出js入口列表
3、style列表
4、找到入口文件
来源:juejin.cn/post/7445090940278276147
大屏适配方案--scale
CSS3的scale等比例缩放
宽度比率 = 当前网页宽度 / 设计稿宽度
高度比率 = 当前网页高度 / 设计稿高度
设计稿: 1920 * 1080
适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * 2)
方案一:根据宽度比率
进行缩放(超宽屏比如9/16的屏幕会出现滚动条)
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
首先基于1920 * 1080进行基础的布局,下面针对两种方案进行实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body,
ul {
margin: 0;
padding: 0;
}
body {
width: 1920px;
height: 1080px;
box-sizing: border-box;
/* 在js中添加translate居中 */
position: relative;
left: 50%;
/* 指定缩放的原点在左上角 */
transform-origin: left top;
}
ul {
width: 100%;
height: 100%;
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
li {
width: 33.333%;
height: 50%;
box-sizing: border-box;
border: 2px solid rgb(198, 9, 135);
font-size: 30px;
}
</style>
</head>
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
<script>
// ...实现适配方案
</script>
</body>
</html>
方案一:根据宽度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
// html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
console.log(currentWidth);
// 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
// 进行缩放
document.body.style = `transform: scale(${scaleRatio})`;
实现效果如下:
这时我们发现在7680 * 2160尺寸下,屏幕根据宽度缩放会出现滚动条,为了解决这个问题,我们就要动态的选择根据宽度缩放还是根据高度缩放。
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
let targetHeight = 1080;
let targetRatio = 16 / 9; // targetWidth /targetHeight
// 当前屏幕html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
// 当前屏幕html的高 || body的高
let currentHeight =
document.documentElement.clientHeight || document.body.clientHeight;
// 当前屏幕宽高比
let currentRatio = currentWidth / currentHeight;
// 默认 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
if (currentRatio > targetRatio) {
scaleRatio = currentHeight / targetHeight;
}
// 进行缩放
document.body.style = `transform: scale(${scaleRatio}) translateX(-50%);`;
效果如下:
这样就可以解决在超宽屏幕下出现滚动条的问题,另外我们做了居中的样式处理,这样在超宽屏幕时,两边留白,内容居中展示显得更加合理些。
来源:juejin.cn/post/7359077652416725018
使用uniapp制作安卓app容器
1. 背景
项目需要做一个安卓app
,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview
就行了吧。没有选择react native
之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。
用webview
也方便快速修复页面问题。
所以最后选择了uniapp
,但是uniapp
本身就是套在一个大的webview
下的, 所以再套一个webview
难免会有一些意想不到的问题,下面就是一些踩过的坑记录。
项目需要做一个安卓app
,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview
就行了吧。没有选择react native
之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。
用webview
也方便快速修复页面问题。
所以最后选择了uniapp
,但是uniapp
本身就是套在一个大的webview
下的, 所以再套一个webview
难免会有一些意想不到的问题,下面就是一些踩过的坑记录。
2. 项目初始化
新建项目就默认模板就行,我只需要壳子。
启动了之后可以看到有两个调试工具

第一个就是网页上常用的vue
调试工具,可以看到vue
组件属性啥的,第二个就是类似chrome
的控制台,但是无法查看元素
,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。
hbuilder
的控制台本身也有一些输出,比如页面的console

但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。
新建项目就默认模板就行,我只需要壳子。
启动了之后可以看到有两个调试工具
第一个就是网页上常用的vue
调试工具,可以看到vue
组件属性啥的,第二个就是类似chrome
的控制台,但是无法查看元素
,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。
hbuilder
的控制台本身也有一些输出,比如页面的console
但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。
3. webview
使用
整个项目很简单,大概就这样一个页面
<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>
整个项目很简单,大概就这样一个页面
<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>
3.1 网页与app
通信
3.1.1. 网页 -> APP
首先要在项目中引入uni.webview.js,这个就相当于jsbridge
,可以让网页操作uniapp
。
初始化完成后会在window
上挂载一个uni
对象,通过uni.postMessage
就能往app
发送消息,app
中监听onMessage
就行。
这里有几个小坑:
- 发送的格式
window.uni.postMessage({ data: 数据 })
,必须要有个字段data
,这样app
才能收到数据。源码
2. 发送的数据不需要序列化成字符串,uniapp
会转换json
。 3. app
在message
事件中接收到事件参数应该这样解构
function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}
首先要在项目中引入uni.webview.js,这个就相当于jsbridge
,可以让网页操作uniapp
。
初始化完成后会在window
上挂载一个uni
对象,通过uni.postMessage
就能往app
发送消息,app
中监听onMessage
就行。
这里有几个小坑:
- 发送的格式
window.uni.postMessage({ data: 数据 })
,必须要有个字段data
,这样app
才能收到数据。源码
2. 发送的数据不需要序列化成字符串,
uniapp
会转换json
。 3. app
在message
事件中接收到事件参数应该这样解构
function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}
3.1.2. APP -> 网页
app
向网页传输消息就直接调用网页的js
就行了。这里我统一封装了一个函数:
// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}
上面的代码例子中出现的currentWebview
需要我们自己去获取。
// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]
// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]
这里也有一个坑,rootWebview.children()
如果你一渲染就获取是无法获取到webview
实例的,具体原因没有深入研究,估计是异步的原因
这里提供两个思路:
- 加一个定时器,延迟获取
webview
,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单。
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
- 你要是觉得定时器不保险,那就使用
plus
的api
手动创建webview
。但是消息处理这块比较麻烦。官网参考
<template>
template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})
app
向网页传输消息就直接调用网页的js
就行了。这里我统一封装了一个函数:
// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}
上面的代码例子中出现的currentWebview
需要我们自己去获取。
// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]
// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]
这里也有一个坑,rootWebview.children()
如果你一渲染就获取是无法获取到webview
实例的,具体原因没有深入研究,估计是异步的原因
这里提供两个思路:
- 加一个定时器,延迟获取
webview
,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单。
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
- 你要是觉得定时器不保险,那就使用
plus
的api
手动创建webview
。但是消息处理这块比较麻烦。官网参考
<template>
template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})
plus.globalEvent.addEventListener
这个是翻源码找到的,主要是我不想改uni.webview.js
的源码,所以只有找到正确的监听事件。
WEB_INVOKE_APPSERVICE
是uniapp
内部定义的一个名字,反正就是用来交互操作的命名空间。
这样基础的互操作就有了。
3.1.3. 整个流程
网页
调用window.uni.postMessage({ data })
=> app
监听(用组件的onMessage
或者自定义的globalEvent
)app
调用网页
定义的函数deliverMessage
并传递参数,网页
中的deliverMessage
内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {
});
};
网页
调用window.uni.postMessage({ data })
=>app
监听(用组件的onMessage
或者自定义的globalEvent
)app
调用网页
定义的函数deliverMessage
并传递参数,网页
中的deliverMessage
内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {
});
};
3.2. 返回拦截
默认情况下,手机按下返回键,app
会响应提示是否退出,但是实际我需要网页进入二级路由的时候,按下手机返回键是返回上一级路由而不是退出。当路由是一级路由时才提示是否退出app
import {
onBackPress,
onShow,
} from '@dcloudio/uni-app'
// 页面当前的路由信息
const pageRoute = shallowRef()
onBackPress(() => {
// tab页正常app返回逻辑
if (pageRoute.value?.isTab) {
return false
} else {
// 二级路由拦截app返回
return true
}
})
pageRoute
是页面当前路由信息,页面通过监听路由变化触发routeChange
事件,将路由信息传给app
。当按下返回键的时候,判断当前路由配置是不是tab
页,如果是就正常退出,不是就拦截返回。
4. 总结
有了通信功能,很多操作就可以实现了,比如获取设备safeArea
,获取设备联网状态等等。
来源:juejin.cn/post/7313740940773097482
即梦AI上线新功能,可一句话生成中文海报
近日,即梦AI升级了图片生成功能,用户使用即梦pc版或app时,选择最新上线的图片2.1生图模型,通过输入文本描述,即可生成带有指定文字的海报。
例如,输入:生成一张含有筷子夹起饺子的冬至插画海报,标题是“Winter Solstice”下方是“冬至”两字,即梦就能按照指令快速完成。
据测试用户反馈,即梦AI新功能已经可以较为准确地生成中文文字,生图效果也更具影视质感。测试期间,用户已衍生出表情包、四格漫画、手写风格等多种玩法,更大限度地释放了创意。
字节豆包大模型团队相关负责人表示,豆包文生图模型通过打通LLM和DIT架构,具备更好的原生中文数据学习能力,并在此基础上强化汉字生成能力,大幅提升了生成效果。不过目前对于复杂的汉字生成还有提升的空间。据即梦相关负责人,团队正持续对文生图功能进行优化升级,近期还将上线对生成文字进行涂抹修改的功能,助力创作者们更好地实现想象力。
即梦AI是字节跳动旗下的AI内容平台,支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。(作者:李双)
收起阅读 »前端实现画中画超简单,让网页飞出浏览器
Document Picture-in-Picture 介绍
今天,我来介绍一个非常酷的前端功能:文档画中画 (Document Picture-in-Picture, 本文简称 PiP)。你有没有想过,网页上的任何内容能悬浮在桌面上?😏
🎬 视频流媒体的画中画功能
你可能已经在视频平台(如腾讯视频
、哔哩哔哩
等网页)见过这种效果:视频播放时,可以点击画中画后。无论你切换页面,它都始终显示在屏幕的最上层,非常适合上班偷偷看电视
💻
在今天的教程中,不仅仅是视频,我将教你如何将任何 HTML 内容放入画中画
模式,无论是动态内容、文本、图片,还是纯炫酷的 div,统统都能“飞”起来。✨
一个如此有趣的功能,在网上却很少有详细的教程来介绍这个功能的使用。于是我决定写一篇详细的教程来教大家如何实现画中画 (建议收藏)😁
体验网址:Treasure-Navigation
📖 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);
});
演示:
👏 现在,我们已经成功创建了一个画中画窗口!
这段代码展示了如何将网页中的元素放入一个新的画中画窗口,并让它悬浮在最上面。非常简单吧
关闭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)
的兼容性会好一点,但是它只能将元素放入画中画窗口。它与本文介绍的 文档画中画(Document Picture-in-Picture)
使用方法也是十分相似的。
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);
}
});
演示:
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);
演示:
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>
在普通页面中显示为粉色
,在画中画自动变为浅绿色
演示:
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 窗口");
});
演示
5️. 监听 PiP 焦点和失焦事件
const pipWindow = await window.documentPictureInPicture.requestWindow({
width: 200,
height: 300
});
pipWindow.addEventListener('focus', () => {
console.log("PiP 窗口进入了焦点状态");
});
pipWindow.addEventListener('blur', () => {
console.log("PiP 窗口失去了焦点");
});
演示
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));
演示
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
new Image() 预加载 为什么比 <img>直接加载要好?
<img>
直接加载对比 new Image()
预加载
1. 加载时机和页面渲染的差异
- 直接渲染到
<img>
标签: 当你直接在 HTML 中通过<img>
标签加载图片时,浏览器在遇到<img>
标签时会立即开始加载图片。这意味着浏览器在渲染页面的过程中,会同步进行图片请求。当页面需要渲染图片时,可能会导致图片显示之前页面的其它部分无法完全显示,或者图片加载的过程中页面会出现闪烁或布局跳动。
这种加载方式是 同步 的,即浏览器渲染页面时,图片的加载和显示是直接相关的。如果图片较大或者网络慢,用户可能会看到空白的占位符,直到图片加载完成。
- 使用
new Image()
和img.src = src
: 这种方式会在后台加载图片,不直接影响页面的渲染。也就是说,图片资源在浏览器缓存中已经加载好了,页面显示图片时,浏览器能快速地从缓存读取图片,而不必等待网络请求。浏览器不会因为加载图片而延迟页面的渲染。
关键点是:通过
new Image()
加载图片会提前发起请求,将图片缓存到浏览器中,这意味着你可以在用户滚动或需要展示图片时,直接从缓存加载,而不需要重新请求网络资源。这个过程是 异步 的。
2. 浏览器的资源管理和缓存
- 图片预加载的缓存: 当你通过
new Image()
加载图片时,图片会被缓存在浏览器的内存中(通常是浏览器的资源缓存),因此如果图片已经被加载过,后续使用该图片时会直接从缓存读取,而不需要重新请求网络资源。
而如果你直接用
<img>
标签来加载图片,浏览器同样会请求并缓存图片,但如果图片在初次加载时不可见(比如在页面下方),用户滚动到该位置时,可能会再次触发网络请求,尤其是在使用懒加载(lazy load)等技术时。如果图片已经预加载过,浏览器就可以从缓存中直接加载,避免了再次请求。
3. 避免页面阻塞
- 直接使用
<img>
:当浏览器在解析页面时遇到<img>
标签,会立即发起网络请求来加载图片。如果图片资源很大或者服务器响应很慢,浏览器可能需要等待这些资源加载完成,才能继续渲染其他部分。这会导致页面的 渲染阻塞,即页面内容渲染较慢,特别是在图片多的情况下。 - 使用
new Image()
预加载:通过new Image()
预加载图片,可以避免渲染时对页面的阻塞。浏览器在后台加载图片,直到需要展示图片时,图片已经准备好了,这样页面展示可以更快,用户体验也更好。
4. 适用场景
- 直接
<img>
标签加载:适用于图片较少且页面上几乎所有图片都需要立即展示的场景。例如,单一图片展示的页面。 new Image()
预加载:适用于图片较多或需要延迟加载的场景,例如动态加载的图片、长页面或者需要懒加载的图片库。它允许你提前将图片加载到浏览器缓存中,减少后续显示时的加载时间。
5. 加载速度和时间
如果从加载速度和时间上来看,两者的差别可能不大,因为它们最终都会发起一次网络请求去加载图片。但是,new Image()
的优势在于:
- 它允许你在图片真正需要显示之前就开始加载,这样当用户需要看到图片时,图片已经在浏览器缓存中,可以即时显示。
- 使用
new Image()
可以提前加载图片,而不会影响页面的渲染顺序和内容显示,不会造成页面的阻塞。
6. 网络请求优化
new Image()
还可以和 并发请求 进行优化。如果你有多个图片需要预加载,可以通过多个 new Image()
实例来并行加载这些图片,而不影响页面的渲染。并且,如果你知道某些图片很可能会被需要(例如图片懒加载场景中的下拉加载图片),你可以提前加载这些图片,确保用户滚动时能立刻看到图片。
7. 总结对比
特性 | <img> 标签加载 | new Image() 预加载 |
---|---|---|
渲染影响 | 直接渲染图片,可能导致页面闪烁或布局跳动 | 异步加载图片,不影响页面渲染 |
缓存 | 图片加载后会缓存,但可能会重复请求 | 图片预先加载到缓存中,避免重复请求 |
适用场景 | 单一图片,少量图片,图片快速加载 | 图片较多,懒加载,预加载 |
加载时机 | 页面渲染时加载,可能导致渲染延迟 | 提前加载,确保图片准备好时显示 |
结论
虽然从技术上讲,直接在 <img>
标签中加载图片和使用 new Image()
设置 src
都会触发相同的图片加载过程,但是 使用 new Image()
进行预加载 提供了更灵活的控制,使得你可以在页面渲染时避免图片加载阻塞,提升页面的加载速度和用户体验。
(补充:代码示例小demo传送门)
来源:juejin.cn/post/7441246880666107931
微信小程序批量自动化部署
CI/CD这个概念很实用,但我们这种小作坊,没有一些很高大上的应用。
最常见的使用场景就是,开发者一键提交分支master,交给工作流工具完成构建,部署的后续操作,自动更新测试或线上环境。
个人博客等项目可以使用Github action来实现,但公司的代码在云效上,我更习惯于使用云效Flow来实现自动化部署。他的操作菜单是可视化的,非常方便,还有一些推送机器人消息的傻瓜化配置插件。
目前遇到一个需求,就是同一个uni-app小程序项目,需要部署到多个不同的小程序上。每个小程序的主要功能类似,但都有一些定制改动。
每次项目发版时,如果要手动挨个在微信开发者工具上切换、上传,会非常繁琐,而且uni-app使用dev命令输出的开发环境微信小程序项目代码也没有优化,正式发版时哪怕只有一个小程序也需要在dev、build两个项目里来回切。
因此非常需要自动化部署来节省精力。
下面梳理一下微信小程序的批量自动化部署实现流程。
准备工作
常见的web项目自动化部署,至少包含代码触发、构建、部署这3个步骤。
其中构建步骤中操作的产物会被打包上传,并在部署步骤中,下载到目标服务器,然后执行后续目录操作、启动等操作。
但是微信小程序的部署不需要这些操作,而是通过在node脚本中执行miniprogram-ci
这个工具的相关方法来实现的。
miniprogram-ci
的相关文档请参考这里。
密钥及IP白名单配置
跟着文档操作,首先需要到微信小程序管理后台的开发设置中进行配置。
点击生成按钮即可创建密钥,关闭后只能重新生成。
将密钥文件下载到安全的位置。由于我们的项目是私有库,这里就直接放到了项目deploy目录下。多个小程序的密钥可以放在一起,默认已经用appId做了区分。
云效Flow的构建集群提供了一组IP地址,将这些IP地址加入白名单即可。地址如下:
47.94.150.88
47.94.150.17
47.93.89.246
123.56.255.38
112.126.70.240
IP地址不在白名单的话,调用上传时会报错。如果在本地调试,别忘了将本机的公网IP加入白名单,或者临时关闭。
构建脚本
uni-app项目使用vite框架,这里用到了.env环境变量的相关功能,原生微信小程序请自行实现或省略此功能。
更新版本号
微信小程序上传时,需要指定版本号。
版本号标准的用法还是放在package.json中,所以我在自动化部署实现过程中,顺便就引入了standard-version
版本号管理。(项目被标记为deprecated,但我没有找到其他适合私有库的版本号管理工具,欢迎指点。)
standard-version
可以自动根据git提交记录生成CHANGELOG.md
。
并按照以下初始规则来生成版本号:
如果上个版本之间的提交只有fixed,更新patch版本号,比如1.0.0
更新到1.0.1
。
否则更新minor版本号,比如1.0.0
更新到1.1.0
。
更新版本号的同时,它会将CHANGELOG.md
与更新版本号以后的package.json
一同提交到git,同时创建一个版本号对应的tag。
在一般项目中这样就足够了,但是如果还想在小程序中展示这个版本号,就会存在问题——无法引入package.json文件。
而且使用wx.getAccountInfoSync虽然也能获取版本号,但只有正式环境能用,在体验版、开发版中是空字符串。
因此,我只能修改部署版本命令,加上了一些后处理脚本,将版本号同步更新到环境变量中。
在package.json的script中,添加以下命令:
{
"scripts": {
"release": "standard-version && node deploy/deploy.js version",
"release:minor": "standard-version -- --release-as minor && node deploy/deploy.js version",
"release:beta": "standard-version -p beta && node deploy/deploy.js version"
}
}
多提一嘴,release:minor是在提交记录只有fixed但又希望更新minor版本时使用的,可以无视默认规则。当然也可以无视所有规则直接指定具体版本号,具体使用可查看文档(github.com/conventiona…
后处理脚本
在deploy目录下,创建deploy.js文件,内容如下:
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')
const program = new Command()
// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')
// 分割每一行
const lines = envContent.split('\n')
// 定义新的内容数组
const newLines = []
// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})
// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))
// 添加文件到暂存区
execSync(`git add ${envPath}`)
// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}
// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')
// 删除旧的标签
execSync(`git tag -d ${tag}`)
// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})
program.parse(process.argv)
这个脚本会读取.env文件,找到VITE_APP_VERSION这一行,将其值更新为package.json中的version,然后将改动合并到前一次的git提交中,也就是standard-version所创建的提交。
没有用dotenv是因为这个工具更适合读取配置,但写入时会丢失注释信息。
构建小程序
如果只有一个小程序,可以略过此步,直接执行构建命令然后上传。
有多个小程序时,需要先执行一些定制脚本,再执行构建。比如至少要做的一项操作是更新appId,在uni-app中,这项配置位于manifest.json中。
在deploy/deploy.js
中添加以下代码:
// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}
try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)
// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid
// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})
这个脚本会读取manifest.json文件,找到mp-weixin.appid这一行,将其值更新为命令行参数中的appid,然后将改动写入manifest.json文件。
调用脚本的命令例子为:
node deploy/deploy.js toggle --appid=你的appid
上传小程序
在deploy/deploy.js
中添加以下代码:
// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')
const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})
program.parse(process.argv)
这个脚本会调用微信小程序的CI接口,将小程序上传到微信服务器。调用脚本的命令例子为:
node deploy/deploy.js upload --appid=你的appid
其中appid在命令行中传入,而version是从package.json中读取的。
完整的deploy.js文件
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')
const program = new Command()
// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')
// 分割每一行
const lines = envContent.split('\n')
// 定义新的内容数组
const newLines = []
// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})
// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))
// 添加文件到暂存区
execSync(`git add ${envPath}`)
// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}
// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')
// 删除旧的标签
execSync(`git tag -d ${tag}`)
// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})
// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}
try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)
// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid
// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})
// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')
const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})
program.parse(process.argv)
如果是原生微信小程序,且使用了npm依赖,只需要在upload之前执行一下构建命令即可:
// 在有需要的时候构建npm
const warning = await ci.packNpm(project, {
ignores: [],
reporter: (infos) => { console.log(infos) }
})
console.warn(warning)
// ci.upload()
在本地调试时,用以下命令即可模拟构建的完整操作了:
node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A
注意这里的build命令,对应package.json中脚本的写法为:
"build:mp-weixin:小程序A": "uni build -p mp-weixin --mode 小程序A",
传入mode参数时,执行时会读取.env.小程序A
中定义的环境变量,从而实现一些定制化的操作。
可以将这组命令写成sh脚本,每个小程序一个,都放在deploy目录下,在Flow工作流中调用。
上传命令执行成功后,微信小程序后台版本管理中就可以看到这个版本了:
后续的提审、发布操作目前仍需人工操作。
配置云效Flow
本地调试正常后,最后来配置云效Flow。
前面的代码触发不变,后面的部署步骤可以直接删除。
构建脚本为:
npm i -g pnpm
pnpm config set registry https://registry.npmmirror.com
pnpm i
node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A
如果有多个小程序,可以配置多个并行步骤:
待优化
依赖应该只需要安装一次,即将安装依赖步骤与构建步骤分开。
(可选)配置通知机器人
在构建步骤窗口的底部,可以添加通知插件。
这里使用的是钉钉机器人,教程参考这里
大致步骤为:
- 在钉钉中拉上同事或者小号,凑满3人,创建一个外部群。
- 在钉钉群的群设置中,添加机器人,获得api接口地址与签名。
- 在云效Flow的钉钉机器人插件中填入接口地址与签名。
此后每次发版,只需提版合并到master分支,等待片刻收到钉钉机器人的提示,就可以准备提审了。
参考来源:
来源:juejin.cn/post/7392558409743548466
为了解决小程序tabbar闪烁的问题,我将小程序重构成了 SPA
(日落西山,每次看到此景,我总是会想到明朝(明朝那些事儿第六部的标题,日落西山))
前言
几个月前,因工作需求,我开发了一个小程序,当时遇到了一个需求,是关于tabbar权限的问题。小程序的用户分两种,普通用户和vip用户,普通用户tabbar有两个,vip用户小程序下面的tabbar有五个。
因为涉及自定义tabbar的问题,所以官方自带的tabbar肯定就不能用了,我们需要自定义tabbar。官方也提供了自定义tabbar的功能。
官网自定义tabbar
官网地址:基础能力 / 自定义 tabBar (qq.com)
{
"tabBar": {
"custom": true,
"list": []
}
}
就是需要在 app.json
中的 tabBar
项指定 custom
字段,需要注意的是 list
字段也需要存在。
然后,在代码根目录下添加入口文件:
custom-tab-bar/index.js
custom-tab-bar/index.json
custom-tab-bar/index.wxml
custom-tab-bar/index.wxss
具体代码,大家可以参考官网案例。
需要注意的是每个tabbar页面 / 组件
都需要在onshow / show
函数中执行以下函数,否则就会出现tabbar按钮切换两次,才会变成选中色的问题。
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}
接下来就是我的思路
我在 custom-tab-bar/index.js
中定义了一个函数,这个函数去判断当前登录人是否为vip,如果是就替换掉tabbar 的数据。
那么之前每个页面的代码就要写成这样
if (typeof this.getTabBar === 'function' &&
this.getTabBar()) {
this.getTabBar().change_tabbar_list()
this.getTabBar().setData({
selected: 0 // 第n个tabbar页面就填 n-1
})
}
ok,我们来看一下效果。注意看视频下方的tabbar,每个页面,第一次点击的时候,有明显的闪烁bug
。(大家也可以参考一下市面上的小程序,小部分的小程序有这个闪烁问题,大部分的小程序没有这个闪烁的问题(如:携程小程序))
bug产生原因
那么我们就要去思考了,为什么人家的小程序没有这个bug呢?
想这个问题前,要先去想这个bug是怎么产生的,我猜测是每个tabbar页面都有个初始化的过程,第一次渲染页面的时候要去重新渲染tabbar,每个页面的tabbar都是从0开始渲染,然后会缓存到每个页面上,所以第二次点击就没有这个bug了。
解决tabbar闪烁问题
为了解决这个问题,我想到了SPA ,也就是只留一个页面,其他的tabbar页面都弄成组件。
效果展示
已经解决,tabbar闪烁的问题。
代码思路,通过wx:if 控制组件的显示隐藏。
源码地址:gitlab.com/wechat-mini…
https克隆地址:gitlab.com/wechat-mini…
写在最后
1、我也是在网上见过别人的一些评论,说如果将小程序重构成这种单页面,会有卡顿问题,我目前没有发现这个问题,可能是我做的小程序功能比较少。
2、至于生命周期,将页面切换成组件后,页面的那些生命周期也肯定都不能使用了,只能用组件的生命周期,我之前开发使用组件的生命周期实现业务逻辑也没什么问题。 触底加载这些也只能换成组件去实现了。
3、小程序最上面的标题,也可以使用以下代码来实现。就是在每个组件初始化的时候要去执行下列代码。
wx.setNavigationBarTitle({
title: '',
});
来源:juejin.cn/post/7317281367111827475
一句话让cursor爬取到大量美女图片!!!
AI编程大大的提高了人们的开发效率。
cursor
cursor是一个集成了GPT4、Claude 3.5等先进LLM的类VScode的编译器,可以理解为在vscode中集成了AI辅助编程助手。
cursor内置了很多LLMs,包括最先进的GPT4s、Claude3.5s和openai最新发布的推理模型o1-preview和o1-mini,在右上角的设置中即可打开相应的模型进行辅助编程。
最常用的快捷键就下面四个:
- Tab:自动填充
- Ctrl+K:编辑代码
- Ctrl+L:(compose模式对话)回答用户关于代码和整个项目的问题,适合复杂的多轮对话,需要处理文件的场景,能长期保存对话历史
- Ctrl+i:(chat模式对话)简单的问答,系统快速的文本,生成实时对话需求
下面将带大家使用cursor去爬取美女图片。这个项目并不复杂,我们使用chat模式来进行对话。我们使用ctrl+i调出对话框,输入要求即可
接着cursor就会给你回复,直接按照回复运行即可。可以看到图片已经爬取出来了
当然,这种方式也能很快的帮我们学习。当我们成品做出来之后,我们可以使用vscode的marscode进行问答,让他告诉我们代码的作用是什么,小编称之为面向实战学习,下面带着大家迅速学习一个简单demo感受一下学习的效率。
marscode
marscode是vscode集成的一款AI插件,能够让AI给用户进行页面内部的问答,这样就不用打开外置的AI应用进行询问,给程序员沉浸式的体验。
这是一个爬取微博热榜的简单小demo
const request = require('request-promise');
const cheerio = require('cheerio');
const createCsvWriter = require('csv-writer').createObjectCsvWriter;
// 目标URL
const url = 'https://tophub.today/n/KqndgxeLl9';
// 发送HTTP请求并解析HTML
request(url)
.then(html => {
const $ = cheerio.load(html);
const hotList = [];
// 选择表格中的每一行
$('table tr').each((index, element) => {
if (index === 0) return; // 跳过表头行
const rank = $(element).find('td:nth-child(1)').text().trim();
const title = $(element).find('td:nth-child(2) a').text().trim();
const heat = $(element).find('td:nth-child(3)').text().trim();
const link = $(element).find('td:nth-child(2) a').attr('href');
hotList.push({ rank, title, heat, link });
});
// 创建CSV写入器
const csvWriter = createCsvWriter({
path: 'hot_list.csv',
header: [
{ id: 'rank', title: 'Rank' },
{ id: 'title', title: 'Title' },
{ id: 'heat', title: 'Heat' },
{ id: 'link', title: 'Link' }
]
});
// 写入CSV文件
csvWriter.writeRecords(hotList)
.then(() => console.log('CSV file has been saved.'))
.catch(err => console.error('Error writing CSV file:', err));
})
.catch(err => console.error('Error fetching the webpage:', err));
我们是没有学过爬虫的哦,里面的内容都看不太懂,我们让AI来带着我们学习。
const request = require('request-promise');
const cheerio = require('cheerio');
const createCsvWriter = require('csv-writer').createObjectCsvWriter;
首先发起疑惑,这个require是什么啊?我们打开marscode选中要询问的代码就可以直接询问
可以看到marscode已经给了我们回复,原来这是es6之前的导入模块的方式。这三个模块的作用分别是发送请求的模块,解析HTML的模块和将爬取到的文本写入csv文件的模块。
我们可以接着询问。我们发现了一个 $ 符号看不懂,我们再问问AI这是什么
更多的就不再演示了,总之通过这种方式能极大的提高学习的效率
来源:juejin.cn/post/7442731383615389733
优雅解决uniapp微信小程序右上角胶囊菜单覆盖问题
前言
大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保持在安全区域,达到低耦合,可复用性强的效果。
一、创建NavbarWrapper.vue组件
大致结构如下:
<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}">
<slot/>
</view>
</template>
<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
}
}
</script>
<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/
box-sizing: border-box;
}
</style>
目的
主要是动态计算statusBarHeight和rightSafeArea的值。
解决方案
在APP端只需一行css代码即可
.navbar-wrapper {
padding-top: var(--status-bar-height);
}
下面是关于--status-bar-height
变量的介绍:
从上图可以知道--status-bar-height
只在APP端是手机实际状态栏高度,在微信小程序是固定的25px
,并不是手机实际状态栏高度;
在微信小程序时,除了状态栏高度还需要获取右上角的胶囊菜单所占宽度,保持导航栏在安全区域。
以下使用uni.getWindowInfo()
和uni.getMenuButtonBoundingClientRect()
来分别获取状态栏高度和胶囊相关信息,api介绍如下图所示:
主要逻辑代码
在NavbarWrapper组件创建时,做相关计算
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif
// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
用法
<NavbarWrapper>
<view class="header">header</view>
</NavbarWrapper>
二、多端效果展示
微信小程序
APP端
H5端
三、源码
NavbarWrapper.vue
<template>
<view class="navbar-wrapper" :style="{
paddingTop: statusBarHeight,
paddingRight: rightSafeArea
}">
<slot/>
</view>
</template>
<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
},
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif
// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
}
</script>
<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/
box-sizing: border-box;
background-color: deeppink;
}
</style>
往期文章回顾
来源:juejin.cn/post/7309361597556719679
uniapp微信小程序授权后得到“微信用户”
背景
近日在开发微信小程序的时候,发现数据库多了很多用户名称是"微信用户"的账号信息。接口的响应信息如下。
(nickName=微信用户, avatarUrl=https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132, gender=0, country=, province=, city=, language=), code=0e1abNFa1dBwRG0lnoJa18qT0i2abNFk)
经过排查,发现官方是对微信授权的接口做出了调整。小程序用户头像昵称获取规则调整公告
根据上面标红的字体说明,官方的意图就是只提供openid和unionid, 不暴露用户头像昵称数据。
基于此才会在新版的接口中返回"微信用户"的信息。
- 针对这个问题,官方提供的解决方案如下。
以上解决方案,表达的意思是新版用户授权的接口中, 官方只会给你提供unionid和openid.
至于用户的昵称和头像,开发者可以提供功能,以用户的意志去完成修改和更新。
tips: 建议授权接口生成用户名和昵称,采用系统默认的方式。
微信授权流程
uniapp代码实现
后端代码
异常分析
//如果你的接口出现如下信信息,该如何处理呢?
# {errMsg: “getUserProfile:fail api scope is not
declared in the privacy agreement“, errno: 112}
出现问题的原因: api 范围未在隐私协议中声明,建议大家更具公告,更新对应的隐私协议。
【设置-服务内容声明-用户隐私保护指引】,更新隐私协议,在第一条:开发者处理的信息中,点击【增加信息类型】,选择需要授权的信息,头像昵称我已经勾选了,所以列表中不显示了,根据需求选择和填写其他内容,最后确定并生成协议。等待隐私协议审核通过。
建议按需添加,以防审核不通过。
为了分辨用户,开发者将在获取你的明示同意后,收集你的微信昵称、头像。
为了显示距离,开发者将在获取你的明示同意后,收集你的位置信息。
开发者收集你的地址,用于获取位置信息。
开发者收集你的发票信息,用于维护消费功能。
为了用户互动,开发者将在获取你的明示同意后,收集你的微信运动步数。
为了通过语音与其他用户交流互动,开发者将在获取你的明示同意后,访问你的麦克风。
开发者收集你选中的照片或视频信息,用于提前上传减少上传时间。
为了上传图片或者视频,开发者将在获取你的明示同意后,访问你的摄像头。
为了登录或者注册,开发者将在获取你的明示同意后,收集你的手机号。
开发者使用你的通讯录(仅写入)权限,用于方便用户联系信息。
开发者收集你的设备信息,用于保障你正常使用网络服务。
开发者收集你的身-份-证号码,用于实名认证后才能继续使用的相关网络服务。
开发者收集你的订单信息,用于方便获取订单信息。
开发者收集你的发布内容,用于用户互动。
开发者收集你的所关注账号,用于用户互动。
开发者收集你的操作日志,用于运营维护。
为了保存图片或者上传图片,开发者将在获取你的明示同意后,使用你的相册(仅写入)权限。
为了用户互动,开发者将在获取你的明示同意后,收集你的车牌号。
开发者访问你的蓝牙,用于设备连接。
开发者使用你的日历(仅写入)权限,用于用户日历日程提醒。
开发者收集你的邮箱,用于在必要时和用户联系。
开发者收集你选中的文件,用于提前上传减少上传时间。
当你选择所需的接口后,需要您填写使用说明。 可以参考上面的内容进行填写。
给大家看一下我申请的接口。折腾半天终于把授权登录给整好了。
做完上述隐私设置后,需要你重新发布自己的小程序。 并且设置成采集用户隐私。
审核通过后就可以啦。如下图, 请一定注意!!!
参考文档
头像昵称填写-微信官方文档
uniapp头像昵称填写
getUserProfile:fail api scope is not declared in the privacy agreement
来源:juejin.cn/post/7332113324651610150
被antdesign的恐怖的scripts吓到了
近日无意中打开antdesign
的package.json,然后就看到一砣恐怖的scripts
"scripts": {
"api-collection": "antd-tools run api-collection",
"authors": "tsx scripts/generate-authors.ts",
"build": "npm run compile && cross-env NODE_OPTIONS='--max-old-space-size=4096' npm run dist",
"changelog": "npm run lint:changelog && tsx scripts/print-changelog.ts",
"check-commit": "tsx scripts/check-commit.ts",
"clean": "antd-tools run clean && rimraf es lib coverage locale dist report.html artifacts.zip oss-artifacts.zip",
"clean:lockfiles": "rimraf package-lock.json yarn.lock",
"precompile": "npm run prestart",
"compile": "npm run clean && antd-tools run compile",
"predeploy": "antd-tools run clean && npm run site && cp CNAME _site && npm run test:site",
"deploy": "gh-pages -d _site -b gh-pages -f",
"deploy:china-mirror": "git checkout gh-pages && git pull origin gh-pages && git push git@gitee.com:ant-design/ant-design.git gh-pages -f",
"predist": "npm run version && npm run token:statistic && npm run token:meta",
"dist": "antd-tools run dist",
"format": "biome format --write .",
"install-react-16": "npm i --no-save --legacy-peer-deps react@16 react-dom@16 @testing-library/react@12",
"install-react-17": "npm i --no-save --legacy-peer-deps react@17 react-dom@17 @testing-library/react@12",
"bun-install-react-16": "bun remove react react-dom @testing-library/react && bun add --no-save react@16 react-dom@16 @testing-library/react@12",
"bun-install-react-17": "bun remove react react-dom @testing-library/react && bun add --no-save react@17 react-dom@17 @testing-library/react@12",
"prelint": "dumi setup",
"lint": "npm run version && npm run tsc && npm run lint:script && npm run lint:biome && npm run lint:md && npm run lint:style && npm run lint:changelog",
"lint:changelog": "tsx scripts/generate-component-changelog.ts",
"lint:deps": "antd-tools run deps-lint",
"lint:md": "remark . -f -q",
"lint:script": "eslint . --cache",
"lint:biome": "biome lint",
"lint:style": "tsx scripts/check-cssinjs.tsx",
"prepare": "is-ci || husky && dumi setup",
"prepublishOnly": "tsx ./scripts/pre-publish.ts",
"prettier": "prettier -c --write . --cache",
"prettier-import-sort": "npm run prettier -- --plugin=@ianvs/prettier-plugin-sort-imports",
"biome": "biome check --write",
"pub": "echo 'Please use `npm publish` instead.'",
"postpublish": "tsx scripts/post-publish.ts",
"presite": "npm run prestart",
"site": "npm i --no-save --legacy-peer-deps react@18.3.0-canary-c3048aab4-20240326 react-dom@18.3.0-canary-c3048aab4-20240326 && dumi build && cp .surgeignore _site",
"size-limit": "size-limit",
"sort:api-table": "antd-tools run sort-api-table",
"sort:package-json": "npx sort-package-json",
"prestart": "npm run version && npm run token:statistic && npm run token:meta && npm run lint:changelog",
"start": "tsx ./scripts/set-node-options.ts cross-env PORT=8001 dumi dev",
"pretest": "npm run version",
"test": "jest --config .jest.js --no-cache",
"test:all": "sh -e ./scripts/test-all.sh",
"test:dekko": "node ./tests/dekko/index.test.js",
"test:image": "jest --config .jest.image.js --no-cache -i -u --forceExit",
"test:node": "npm run version && jest --config .jest.node.js --no-cache",
"test:package-diff": "antd-tools run package-diff",
"test:site": "jest --config .jest.site.js",
"test:site-update": "npm run site && npm run test:site -- -u",
"test:update": "jest --config .jest.js --no-cache -u",
"test:visual-regression": "tsx scripts/visual-regression/build.ts",
"token:meta": "tsx scripts/generate-token-meta.ts",
"token:statistic": "tsx scripts/collect-token-statistic.ts",
"tsc": "tsc --noEmit",
"tsc:old": "tsc --noEmit -p tsconfig-old-react.json",
"version": "tsx scripts/generate-version.ts"
},
面对如此复杂的scripts
,有没有被吓到。
相信我,在团队开发中,不要说团队成员,就算是开发者本身一段时间后也不一定能一眼就看到每一条脚本的作用了。
怎么呢?最好是加点注释
但是众所周知,package.json
是不支持注释了。
这里给大家推荐一个VSCODE
插件json_comments_extension,可以用来给任意JSON
文件添加注释.
效果如下:
开源推荐
以下是我的一大波开源项目推荐:
- 全流程一健化React/Vue/Nodejs国际化方案 - VoerkaI18n
- 极致优雅的状态管理库 - AutoStore
- 无以伦比的React表单开发库 - speedform
- 终端界面开发增强库 - Logsets
- 简单的日志输出库 - VoerkaLogger
- 装饰器开发 - FlexDecorators
- 有限状态机库 - FlexState
- 通用函数工具库 - FlexTools
- 小巧优雅的CSS-IN-JS库 - flexstyled
- 为JSON文件添加注释的VSCODE插件 - json_comments_extension
- 开发交互式命令行程序库 - mixcli
- 强大的字符串插值变量处理工具库 - flexvars
- 前端link调试辅助工具 - yald
- 异步信号 - asyncsignal
- React/Vue/WebComponent树组件 - LiteTree
来源:juejin.cn/post/7442573821444227109
Canvas 轻量图文编辑器的一些实践
1. 前言
简而言之,我们需要一个能够在 H5 端和桌面端使用的轻量级图文编辑器。具体的使用流程是在桌面端制作编辑模板(上传一张底图,指定编辑区域的大小),然后在 H5 端允许用户在模板的基础之上添加文本,图片,支持对文本图片的多种编辑等。
2. 核心问题和分析
主要诉求是需要自研一套商品图文定制编辑器,在 PC 上支持模板定制,在 H5 上支持图文编辑。模板定制主要是确定底图的编辑区域,图文编辑器则是在底图上添加图片和文字。
2.1 社区现状
在图文编辑器上,目前社区中各式各样的编辑器非常丰富:
- 专业的修图软件:PS、Pixelmator 等
- 手机 App:美图秀秀、Picsart 等,功能也非常完善且强大,不比 PS 差
- 轻量级编辑器:视频封面编辑、公众号图文排版、商品定制等面向业务场景
PhotoShop | Pixelmator |
---|---|
![]() | ![]() |
美图秀秀 | Picsart |
![]() | ![]() |
在 Web 上的编辑器种类也非常丰富,毕竟 canvas 能做的事情非常多。比如 miniPaint基本复刻了 ps,基于 farbic.js的 Pintura.和 tui.image-editor,基于 Konva的 polotno等等。这些编辑器也基本是个 app 级别的应用了。
miniPaint | tui.image-editor |
---|---|
![]() | ![]() |
polotno | pintura |
![]() | ![]() |
总结一下:
1、不论是软件型应用还是 Web 编辑器,一种是做得非常通用的编辑器,功能丰富且完善,另一种就是面向业务流程定制的轻量型编辑器,只有一些特定交互操作和属性配置能力,可操作内容很少;
2、上述的这些 Web 编辑器大部分都是在 PC 上被使用,在手机上的编辑器也基本是在 Native 容器里开发。所以可以参考的 H5 编辑器基本没有。
3、PC 和 H5 编辑器一个明显的不同是,在 PC 上编辑操作,是选中元素后,元素的属性在工具栏或侧边栏进行编辑,画布上的操作只有缩放和旋转。在 H5 上的编辑器,元素选中后的操作会主要放在四个锚点控制器上,添加自定义操作,其余一些次相关的操作放在底部操作栏。所以在设计和实现这个编辑器的过程中,我们参考了很多类似手机 App 的交互。
2.2 分析
操作流程
1、在 PC 设置模板,上传底图,并设置定制区域,定制区域可调整
2、在 H5 上基于模板进行图文编辑,可添加图片和文字,文字可修改字体 颜色 大小。同时可控制元素的缩放旋转、层级移动、删除和复制。
3、最后基于模板和元素,导出定制图。
我们这次的场景显然只需要一个轻量型的图文编辑器,技术上如何选型?
- 如果基于完整的第三方编辑类库(如 polotno),太重了,可能有现成的功能,但改造成本更高;
- 基于图形处理库(封装了 Cavnas 或者 SVG 的 API)直接开发会更容易管理,但可能需要从头实现一些功能。
我们准备基于 Konva 来实现这次的编辑器需求。也想借这次机会,沉淀一些通用的编辑能力,如元素锚点操作的控制、拖转限制的计算逻辑、蒙层遮罩的绘制逻辑、坐标转换的逻辑等等。
Why Konva?
Konva 和 Fabric 都是比较热门的开源 2D 图形库,封装了 Canvas 的一系列 API。
Farbic | Konva |
---|---|
比较老牌,比 Konva上线时间更早一些。 | 使用 TypeScript 编写,TS 原生支持 |
常用转换(放大、缩小、拖拽)都已经封装好,内置了丰富的笔刷,基本的对齐、标线都有,特别适合用 Canvas 写交互性的界面 | 渲染分层比较清晰,Stage -> Layer -> Gr0up -> Shape |
代码集成度比较高,内置了可交互富文本(纯 Canvas 实现) | 代码简洁、干净,易于阅读 |
代码使用 ES5开发,不能很好的支持 TypeScript,开发效率可能会有影响 | 文档清晰,容易上手 |
由于库本身集成了很多功能点,代码包的大小偏大(压缩后308 kB) | 核心代码精简,代码包较小(压缩后155 kB |
细节功能还需要完善,比如标线系统实现相对简单 | 部分功能实现基于 DOM(富文本) |
. | 后起之秀,周边生态还比较薄弱 |
2.3 编辑器设计思路
编辑器按照图层叠加的顺序自上而下是 底图 -> 蒙层 -> 元素 -> 控制器
3. 详细功能设计
3.1 数据
3.1.1 数据格式定制
目前支持两种编辑区域,圆形和矩形。编辑区域的数据类型为
export type EditAreaType = RectArea | CircleArea;
export interface RectArea {
/** 类型 */
type: 'Rect';
/** 属性 */
attrs: { x: number, y: number, width: number, height: number };
}
export interface CircleArea {
/** 类型 */
type: 'Circle';
/** 属性 */
attrs: { x: number, y: number, radius: number };
}
其中,x,y 均是相对于底图所在容器的坐标。
3.1.2 坐标转换
由于服务端考虑到数据流量成本,在PC和H5的底图会做分辨率的限制,例如在PC上传的底图是 1200x1200,在 H5 上提供的底图是 400x400(但最后合成的时候会用原图)。因此定义编辑器数据过程中,元素和蒙层的坐标不能相对于底图,需要相当于容器大小计算。同时能够互相转换。
如下图所示,用户可以再 PC 端定制编辑区域的大小和位置,然后将模板的数据导出到 h5。这里的问题就是 PC 端制作的模板数据(底图,编辑区域相对于容器的位置,宽高)如何做转换的问题。
但本质上也是三个坐标系之间的转换问题。第一个坐标系是 PC 端底图的容器,第二个坐标系是图片底图本身,第三个坐标系是 h5 端底图的容器。底图填充容器的逻辑为:保持宽高比,填满容器的宽或高,另一个方向上居中处理。
用户在定制编辑区域的时候其实是以底图为坐标系的,但为了方便处理,我们将编辑区域的数据保存为以容器为坐标系。这样在 h5 端加载编辑区域的时候需要一套转换逻辑。实际的转换过程如下图所示,我们只需要计算出将底图填充到两个容器的的变换的 ”差“,或者说两个变换结果之间的变换即可,然后就是将求出的变换应用到编辑区域或具体的元素上。
实际的代码可能更好理解一些
/**
* 映射编辑区域,将编辑区域从旧容器映射到新容器
* @param area 原始编辑区域数据
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns 映射后的编辑区域 EditAreaType
*/
export const projectEditArea = (
area: EditAreaType,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const { type, attrs } = area;
// 编辑区域相对于旧的容器的 transform
const transform = {
x: attrs.x,
y: attrs.y,
rotation: 0,
scaleX: 1,
scaleY: 1,
};
// 编辑区域相对于旧容器的 transform 转换为相对于 新容器的 transform
const newTransform = projectTransform(transform, ratio, containerSize, newContainerSize);
// 编辑区域是矩形
if (type === 'Rect') {
const { width, height } = attrs as { width: number, height: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
width: width * newTransform.scaleX,
height: height * newTransform.scaleY,
},
};
}
// 编辑区域是圆形
if (type === 'Circle') {
attrs as { x: number, y: number, radius: number };
const { radius } = attrs as { radius: number };
return {
type,
attrs: {
x: newTransform.x,
y: newTransform.y,
radius: radius * newTransform.scaleX,
},
};
}
return area;
};
/**
* 映射元素的形变
* @param transform 原始容器下的形变
* @param ratio 底图比例
* @param containerSize 原始容器尺寸
* @param newContainerSize 新容器尺寸
* @returns { TransformAttrs } 新容器下的形变
*/
export const projectTransform = (
transform: TransformAttrs,
ratio: number,
containerSize: Vector2,
newContainerSize: Vector2,
) => {
const {
x, y, rotation, scaleX, scaleY,
} = transform;
const [oldContainerWidth, oldContainerHeight] = containerSize;
const oldContainerRatio = oldContainerWidth / oldContainerHeight;
// 底图相对于旧容器的位置,按比例缩放后居中
let origin: null | { x: number, y: number } = null;
// 底图在旧容器按比例缩放后的 size
let imgSize: null | { width: number, height: number } = null;
// 图片宽高比 < 旧容器宽高比 旧容器更宽,横向有空白
if (ratio < oldContainerRatio) {
imgSize = {
height: oldContainerHeight,
width: oldContainerHeight * ratio,
};
origin = {
x: (oldContainerWidth - oldContainerHeight * ratio) / 2,
y: 0,
};
} else {
// 图片宽高比 > 容器宽高比 旧容器更高,上下有空白
imgSize = {
width: oldContainerWidth,
height: oldContainerWidth / ratio,
};
origin = {
x: 0,
y: (oldContainerHeight - oldContainerWidth / ratio) / 2,
};
}
const [newContainerWidth, newContainerHeight] = newContainerSize;
const newContainerRatio = newContainerWidth / newContainerHeight;
let newOrigin: null | { x: number, y: number } = null;
let newImgSize: null | { width: number, height: number } = null;
// 底图比例小于新容器的宽高比,新容器更宽,缩放后横向有空白
if (ratio < newContainerRatio) {
newImgSize = {
width: newContainerHeight * ratio,
height: newContainerHeight,
};
newOrigin = {
y: 0,
x: (newContainerWidth - newContainerHeight * ratio) / 2,
};
} else {
// 底图比例大于新容器的宽高比,新容器更高,缩放后上下有空白
newImgSize = {
width: newContainerWidth,
height: newContainerWidth / ratio,
};
newOrigin = {
x: 0,
y: (newContainerHeight - newContainerWidth / ratio) / 2,
};
}
// 保持宽高比
// 计算旧容器内底图到新容器内底图的缩放比例
const scale = Math.min(newImgSize.width / imgSize.width, newImgSize.height / imgSize.height);
// 累积两次缩放,实现到新容器保持宽高比缩放效果
const newScaleX = scaleX * scale;
const newScaleY = scaleY * scale;
// 编辑区域相对于旧容器底图的位置转换为相对于新容器底图的位置
const newX = (x - origin.x) * scale + newOrigin.x;
const newY = (y - origin.y) * scale + newOrigin.y;
return {
x: newX, y: newY, rotation, scaleX: newScaleX, scaleY: newScaleY,
};
};
3.2 元素操作
3.2.1 缩放 && 旋转元素
缩放和旋转元素的功能如下图所示,要求按住元素右下角的 icon 的时候,可以绕元素中心旋转元素或缩放元素。
这里最好是有一些 2维 平面上仿射变换的知识,理解起来会更轻松,可以参考 闫令琪关于计算机图形学入门的课程中的介绍,这里就直接介绍解法了。
上面动图中所展示的一共有三种仿射变换,缩放,旋转,还有平移。缩放和旋转都很明显,但是为什么有平移 ?因为 Konva 默认的旋转是围绕 ”左上角“ 的,而实际位移的又是 “右下角”,所以如果想要一个围绕中心旋转的效果,就需要移动 “左上角” 把 “右下角”的位移抵消掉。举个例子,放大的时候,右下角向编辑器右下方移动,左上角向编辑器左上方移动,他们的位移方向总是相反且距离相等。
这里我们只需要在拖拽过程中计算出此刻 ”右下角“ 和元素中心构成的向量 和 上个时刻 ”右下角“ 和元素中心构成的向量,之间的比值,角度,和位移。然后再将这三中变换应用到元素上即可,如下图所示,具体的代码这里不再讲解。
3.2.2 拖拽区域限制
元素的拖拽范围限制是一个常见的问题,h5 上期望的效果为元素不可拖出蒙版所在区域,也就是 h5 上底图实际所在的区域。
实现拖拽范围限制功能的一个思路是在拖拽的回调函数中判断当前的元素坐标是否越界,如果越界则修改元素的坐标为不越界的合法坐标。拖动是一个连续的过程,元素在被拖出限定区域之前会有一个临界的时刻,在此之前元素完全在限定区域内,在此之后,元素开始被拖出限定区域。所以,将元素限制在编辑区域内就是要在元素将要离开的最后一刻,修改元素下一刻的位置把它拉回来。
Konva 也直接提供了一个元素的 dragBoundFunc(pos: Konva.vector2d) => Konva.vector2d
函数,其入参是下一个拖动过程中下一个时刻元素 “左上角” 本来的坐标,返回值是下一个时刻元素 “左上角” 最终的坐标。该函数会在拖动过程中不断执行,只需在此函数中填入限制逻辑即可。
需要注意的是,这里面有两个棘手的问题
- 由于元素自身支持旋转,元素的 “左上角” 并不一定一直处于左上角的位置
- 只有元素 “左上角” 下一时刻的坐标,无法计算下一个时刻元素是否越界
这两个问题的解决过程可谓是一波三折。这里需要注意两个点:一是,拖拽是一个连续的过程,拖拽的过程中只有位移,没有其他变换。二是,我们知道的不仅仅是 dragBoundFunc
传入的下一个时刻的 “左上角” 的坐标,我们还可以计算出当前时刻的元素的四个顶点的坐标。
所以,我们可以计算出下一个时刻 “左上角” 坐标和此刻 “左上角” 坐标的偏移量,从而计算出下一个时刻元素的四个顶点的坐标。然后检测,下个时刻的元素是否在限制区域内即可。如下图所示。
好的,现在我们找到了那个将要越界的时刻,我们该如何计算出一个合法的坐标作为下个时刻元素 “左上角” 的坐标 ?你不能直接把边界值,minX minY maxX maxY
这些值返回,因为“左上角”不一定在左上角。
那如果我找到越界的那个点,然后把对应的点和边界对齐,然后再通过三角函数计算呢 ?就像下图中画的这样。
当然可以 😂 ,但是这也太复杂,太不优雅了,你还要获取元素当前旋转的角度,还要判断到底是哪个点越界 ...
有没有更快更简单的方法,当然也有,这又不是在造火箭。如果精确解很困难,找到一个准确度还不错的近似解就是有价值的。 越界的上一刻还是合法的,我们可以“时间回溯”,用上一个时刻 左上角合法的坐标来返回就行了。
if(crossLeft || crossRight || crossTop || crossBottom){
pos = lastPos;
}else {
lastPos = pos;
}
到此为止就已经能实现开头动图中的效果了。
3.3 控制器
Konva 虽然提供了 Transfomer,可以用于实现拖拽缩放、旋转元素。但在 H5 上对操作功能做了定制,如调整层级,删除元素等等,仍然需要自己定义和实现一个元素控制器。
如下图所示,控制器主要包含虚线边框和四角的可点击 icon。要求点击 icon 分别实现弹窗调整层级,复制,删除,按住拖拽缩放大小的能力。
3.3.1 单例模式
控制器最开始是根据元素实例化的,即每添加一个元素都有一个控制器实例。元素被激活(点击)时会显示该元素的控制器 同时隐藏其他所有控制器,元素失焦之后会隐藏该元素的控制器。拖拽元素,缩放元素的过程中需要同步元素的大小到其自身的控制器。
如上图所示,每个 Shape 类都有一个控制器属性,绘制控制器的时候,会传入包含icon 的回调函数的配置。Shape 的拖拽,缩放过程中需要调用控制器提供的公有方法 updateByShape
来同步位置和缩放比例。
这种做法较为简单,易于理解,但会带来以下两个问题
- 画布上的 Shape 增多,难以区分不同元素的 Shape,对于调整元素之间的层级关系(zIndex)造成困难。
- 画布上的控制器的 Shape 增多,可能会造成性能变差。
- 控制器和 Shape 类混杂在一起,概念不清晰,代码上不好维护。
将控制器和 Shape 类拆分后,两个类的职责更单一。 Shape 类面相外部导出,可以做更多定制。控制器类只面相交互,实现编辑功能。
后面梳理后发现并不需要多个控制器实例子,同一时刻处于激活状态的元素只有一个,不会同时编辑(拖拽,缩放)两个元素。使用一个控制器实例,能够减少画布上的 Shape,便于控制元素的层级。后续的代码逐步演变成下图所示。
控制器通过 id 关联当前激活的 ShapeElement
, ShapeElement
类是对 Konva.Shape 类的简单包装,在其上添加了一些生命周期方法和导出方法等。 而控制器类中则实现了 缩放,拖拽等编辑能力,这种模式下,用户缩放和拖拽的其实是外层的控制器,然后控制器再将这些编辑操作通过 syncBorderRect
方法同步到当前激活的 ShapeElement
。
而为了实现点击不同的 ShapeElement
时切换控制器的效果,我们提供了 updateByShapeElement
方法,在 shape 的 onClick 回调中,只需要调用该方法即可。
在这种模式下,原来控制器位于蒙层之上的效果也容易实现了。如下图所示,画布上从下到上分别是:底图,文本/图片元素,蒙层,控制器。
3.3.2 判断当前选中元素
实现当前控制器的另一个难点在于,元素处于蒙版的遮盖的时候,点击元素如何唤起控制器。如上图所示,当元素完全被蒙版遮盖的时候,Konva 提供的元素的 onClick 事件是不会触发的。
这样只能回到在 canvas 上实现点击事件的思路,监听点击事件,根据点击事件的坐标和元素的位置关系来判断选中的元素。
具体的逻辑为:
- 获取点击事件中的坐标
- 通过
d3-polygon
提供的方法判断点击事件的坐标在不在元素的包围盒中。 - 排序找到命中的最上层的元素
- 激活对应元素,直接执行元素的 onClick 回调函数。
3.4 蒙层
3.4.1 蒙层绘制
蒙层的功能主要有两个:1. PC 端方便用户定制编辑区域的大小。2 H5 端起到编辑区域外起到半透明遮盖的效果,编辑区域内可视的效果。
蒙层的元素主要有三个部分,一是背景的半透明的黑色区域,二是拖拽编辑区域大小时外层的框所在的矩形,三是实现透明效果的矩形。可拖拽,缩放的透明矩形框的实现是 Konva Rect + Konva Transformer
,借助了 transfomer
提供的能力实现编辑区域的缩放。而透明效果的矩形主要是借助 Konva Shape
的 sceneFunc
定制形状的能力,通过 canvas 中的 clip 函数实现透明的矩形或者圆形的效果。
3.4.2 导出特定区域
导出图片时限定只导出编辑区域内的功能主要依赖 Konva 提供的 clipFunc
函数,该函数会传入 canvas2d 绘制上下文,只需要绘制出特定的区域,konva 会自动帮我们只导出区域内的内容。
4. 总结
本文介绍了基于 Konva 实现 H5端的轻量级图文编辑器的一种方法,在实现这个轻量级的图文编辑器的过程中我们总结了设计思路和常见的问题处理方案。当然,编辑器的实现是一个需要不断打磨交互和细节的过程,比如像拖拽过程中的辅助线提示、支持文本和图片更丰富的属性等等。篇幅所限,这里不再展开介绍了。希望本文对有志于动手实现编辑器的前端同学能有所助益。
来源:juejin.cn/post/7312243176835334196
three.js实现3D汽车展厅效果展示
今天简单实现一个three.js的小Demo,加强自己对three知识的掌握与学习,只有在项目中才能灵活将所学知识运用起来,话不多说直接开始。
相关源码和模型的下载链接地址 点击链接进行跳转
项目搭建
本案例还是借助框架书写three项目,借用vite构建工具搭建vue项目,搭建完成之后,用编辑器打开该项目,在终端执行 npm i 安装一下依赖,安装完成之后终端在安装 npm i three 即可。
因为我搭建的是vue3项目,为了便于代码的可读性,所以我将three.js代码单独抽离放在一个组件当中,在App根组件中进入引入该组件。具体如下:
<template>
<!-- 3D汽车展厅 -->
<CarShowroom></CarShowroom>
</template>
<script setup>
import CarShowroom from './components/CarShowroom.vue';
</script>
<style lang="less">
*{
margin: 0;
padding: 0;
}
</style>
初始化three.js代码
three.js开启必须用到的基础代码如下:
导入three库:
import * as THREE from 'three'
初始化场景:
const scene = new THREE.Scene()
初始化相机:
// 创建相机
const camera = new THREE.PerspectiveCamera(40,window.innerWidth / window.innerHeight,0.1,1000)
camera.position.set(4.25,1.4,-4.5)
初始化渲染器:
// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth,window.innerHeight)
document.body.appendChild(renderer.domElement)
监听屏幕大小的改变,修改渲染器的宽高和相机的比例:
window.addEventListener("resize",()=>{
renderer.setSize(window.innerWidth,window.innerHeight)
camera.aspect = window.innerWidth/window.innerHeight
camera.updateProjectionMatrix()
})
导入轨道控制器:
// 添加轨道控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
// 添加控制器
const controls = new OrbitControls(camera,renderer.domElement)
controls.enableDamping = true // 设置控制阻尼
设置渲染函数:
// 设置渲染函数
const render = (time) =>{
controls.update()
renderer.render(scene,camera)
requestAnimationFrame(render)
}
render()
ok,写完基础代码之后,接下来开始具体的Demo实操。
加载汽车模型
通过使用模型加载器GLTFLoader,然后使用DRACOLoader加载Draco压缩过的模型可以显著减小模型文件体积,从而加快加载速度和提高用户体验。代码如下:
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
// 加载汽车模型
const loader = new GLTFLoader()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath("/draco/")
loader.setDRACOLoader(dracoLoader)
loader.load("/public/model/Lamborghini.glb",(gltf)=>{
scene.add(gltf.scene)
})
模型加载完成,画面如下:
因为没有灯光,所以我们需要给一个灯光让模型展现出来,这里设置一下环境光源:
// 设置环境光源
const ambientLight = new THREE.AmbientLight('#fff',0.5)
scene.add(ambientLight)
设置展厅效果
这里通过three库中自带的一些模型来实现展厅的效果,如下:
设置地板样式
// 设置地板样式
const floorGeometry = new THREE.PlaneGeometry(20,20)
const floormaterial = new THREE.MeshPhysicalMaterial({
side: THREE.DoubleSide,
color: 0x808080,
metalness: 0, // 设置金属度
roughness: 0.1, // 设置粗糙度
wireframe: false // 关闭网格线
})
const mesh = new THREE.Mesh(floorGeometry,floormaterial)
mesh.rotation.x = Math.PI / 2
scene.add(mesh)
底部样式设置完,设置一个圆柱体将整个地板进行包裹:
// 设置圆柱体模拟展厅
const cylinder = new THREE.CylinderGeometry(12,12,20,32)
const cylindermaterial = new THREE.MeshPhysicalMaterial({
color: 0x6c6c6c,
side: THREE.DoubleSide
})
const cylinderMesh = new THREE.Mesh(cylinder,cylindermaterial)
scene.add(cylinderMesh)
接下来在圆柱体中设置一个聚光灯,让聚光灯偏垂直照射汽车模型,如下:
// 设置聚光灯(让汽车更具有立体金属感)
const spotLight = new THREE.SpotLight('#fff',2)
spotLight.angle = Math.PI / 8 // 散射角度,和水平线的夹角
spotLight.penumbra = 0.2 // 横向,聚光锥的半影衰减百分比
spotLight.decay = 2 // 纵向,沿着光照距离的衰减量
spotLight.distance = 30
spotLight.shadow.radius = 10
spotLight.shadow.mapSize.set(4096,4096)
spotLight.position.set(-5,10,1)
spotLight.target.position.set(0,0,0) // 光照射的方向
spotLight.castShadow = true
scene.add(spotLight)
为了不让展厅穿帮,这里将控制器的缩放以及旋转角度进行一个限制,让其只能在展厅中灵活查看而不能跑到展厅外面去:
controls.maxDistance = 10 // 最大缩放距离
controls.minDistance = 1 // 最小缩放距离
controls.minPolarAngle = 0 // 最小旋转角度
controls.maxPolarAngle = 85 / 360 * 2 * Math.PI // 最大旋转角度
设置GUI面板动态控制车身操作
这里我使用three.js库中自带的gui库,来动态的改变车身相关操作,因为我仅仅是控制车身材质和玻璃材质相关的数据操作,这里就线设置一下其相关的材质:
// 车身材质
let bodyMaterial = new THREE.MeshPhysicalMaterial({
color: 'red',
metalness: 1,
roughness: 0.5,
clearcoat: 1.0,
clearcoatRoughness: 0.03
})
// 玻璃材质
let glassMaterial = new THREE.MeshPhysicalMaterial({
color: '#793e3e',
metalness: 0.25,
roughness: 0,
transmission: 1.0 // 透光性
})
在glb模型中,通过traverse函数遍历场景中的所有对象(包括Mesh、Gr0up、Camera、Light等),并对这些对象进行相应操作或处理(这里的门操作后面会讲解到):
loader.load("/public/model/Lamborghini.glb",(gltf)=>{
const carModel = gltf.scene
carModel.rotation.y = Math.PI
carModel.traverse((obj)=>{
if(obj.name === 'Object_103' || obj.name === 'Object_64' || obj.name === 'Object_77'){
// 车身
obj.material = bodyMaterial
}else if(obj.name === 'Object_90'){
// 玻璃
obj.material = glassMaterial
}else if(obj.name === 'Empty001_16' || obj.name === 'Empty002_20'){
// 门
// doors.push(obj)
}else{
return true
}
})
scene.add(gltf.scene)
})
最后得到的结果如下:
接下来通过控制面板来动态的监视汽车模型的车身和玻璃材质:
// 设置gui模板控制
// 修改默认面板名称
gui.domElement.parentNode.querySelector('.title').textContent = '3D汽车动态操作'
const bodyChange = gui.addFolder("车身材质设置")
bodyChange.close() // 默认关闭状态
bodyChange.addColor(bodyMaterial,'color').name('车身颜色').onChange(value=>{
bodyMaterial.color.set(value)
})
bodyChange.add(bodyMaterial,'metalness',0,1).name('金属度').onChange(value=>{
bodyMaterial.metalness = value
})
bodyChange.add(bodyMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
bodyMaterial.roughness = value
})
bodyChange.add(bodyMaterial,'clearcoat',0,1).name('清漆强度').onChange(value=>{
bodyMaterial.clearcoat = value
})
bodyChange.add(bodyMaterial,'clearcoatRoughness',0,1).name('清漆层粗糙度').onChange(value=>{
bodyMaterial.clearcoatRoughness = value
})
const glassChange = gui.addFolder("玻璃设置")
glassChange.close() // 默认关闭状态
glassChange.addColor(glassMaterial,'color').name('玻璃颜色').onChange(value=>{
glassMaterial.color.set(value)
})
glassChange.add(glassMaterial,'metalness',0,1).name('金属度').onChange(value=>{
glassMaterial.metalness = value
})
glassChange.add(glassMaterial,'roughness',0,1).name('粗糙度').onChange(value=>{
glassMaterial.roughness = value
})
glassChange.add(glassMaterial,'transmission',0,1).name('透光性').onChange(value=>{
glassMaterial.transmission = value
})
车门操作与车身视角展示
这里依然用GUI控制面板来动态实现开关车门以及车内车外视角动态切换的操作,如下:
var obj = { carRightOpen,carLeftOpen,carRightClose,carLeftClose,carIn,carOut }
// 设置车身动态操作
const doChange = gui.addFolder("车身动态操作设置")
doChange.close() // 默认关闭状态
doChange.add(obj, "carLeftOpen").name('打开左车门')
doChange.add(obj, "carRightOpen").name('打开右车门')
doChange.add(obj, "carLeftClose").name('关闭左车门')
doChange.add(obj, "carRightClose").name('关闭右车门')
doChange.add(obj, "carIn").name('车内视角')
doChange.add(obj, "carOut").name('车外视角')
每个操作都对应一个函数,如下:
// 打开左车门
const carLeftOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[1])
}
// 打开右车门
const carRightOpen = () => {
setAnimationDoor({ x: 0 }, { x: Math.PI / 3 }, doors[0])
}
// 关闭左车门
const carLeftClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[1])
}
// 关闭右车门
const carRightClose = () => {
setAnimationDoor({ x: Math.PI / 3 }, { x: 0 }, doors[0])
}
// 车内视角
const carIn = () => {
setAnimationCamera({ cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 }, { cx: -0.27, cy: 0.83, cz: 0.60, ox: 0, oy: 0.5, oz: -3 });
}
// 车外视角
const carOut = () => {
setAnimationCamera({ cx: -0.27, cy: 0.83, cz: 0.6, ox: 0, oy: 0.5, oz: -3 }, { cx: 4.25, cy: 1.4, cz: -4.5, ox: 0, oy: 0.5, oz: 0 });
}
这里使用了补间动画tween.js,其github网址为 github.com/tweenjs/twe… ,终端安装其第三方插件之后,直接引入即可,如下(这里不再过多介绍该库的使用,想学习的可以自行寻找其官方文档学习):
接下来借助tween.js库实现补间动画,如下:
// 设置补间动画
const setAnimationDoor = (start, end, mesh) => {
const tween = new TWEEN.Tween(start).to(end, 1000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
mesh.rotation.x = that.x
})
tween.start()
}
const setAnimationCamera = (start, end) => {
const tween = new TWEEN.Tween(start).to(end, 3000).easing(TWEEN.Easing.Quadratic.Out)
tween.onUpdate((that) => {
// camera.postition 和 controls.target 一起使用
camera.position.set(that.cx, that.cy, that.cz)
controls.target.set(that.ox, that.oy, that.oz)
})
tween.start()
}
最终实现的效果如下:
点击查看车内视角的话,画面如下:
设置手动点击打开关闭车门
通过设置监听点击事件函数来动态实现打开关闭车门:
// 设置点击打开车门的动画效果
window.addEventListener('click', onPointClick);
function onPointClick(event) {
let pointer = {}
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = - (event.clientY / window.innerHeight) * 2 + 1;
var vector = new THREE.Vector2(pointer.x, pointer.y)
var raycaster = new THREE.Raycaster()
raycaster.setFromCamera(vector, camera)
let intersects = raycaster.intersectObjects(scene.children);
intersects.forEach((item) => {
if (item.object.name === 'Object_64' || item.object.name === 'Object_77') {
if (!carStatus || carStatus === 'close') {
carLeftOpen()
carRightOpen()
} else {
carLeftClose()
carRightClose()
}
}
})
}
然后给每个车门设置汽车状态,如下:
设置图片背景
为了让展厅更具有视觉效果,接下来设置一个画面背景让其更具有画面感,如下:
// 创建聚光灯函数
const createSpotlight = (color) => {
const newObj = new THREE.SpotLight(color, 2);
newObj.castShadow = true;
newObj.angle = Math.PI / 6;;
newObj.penumbra = 0.2;
newObj.decay = 2;
newObj.distance = 50;
return newObj;
}
// 设置图片背景
const spotLight1 = createSpotlight('#ffffff');
const texture = new THREE.TextureLoader().load('src/assets/imgs/奥特曼.jpg')
spotLight1.position.set(0, 3, 0);
spotLight1.target.position.set(-10, 3, 10)
spotLight1.map = texture
const lightHelper = new THREE.SpotLightHelper(spotLight1);
scene.add(spotLight1);
最终呈现的效果如下:
demo做完,本案例的完整代码获取 地址
来源:juejin.cn/post/7307146429004333094
实现敏感字段脱敏注解@Sensitive
前言
在B2C项目中,就以电商项目举例,都有前台与后台。并且这类项目的后台往往都会开放给公司内大部分人,甚至有些是将电商项目作为Saas服务提供给外部厂商的,这样后台中记录的用户数据就成为一个风险点,随着越来越多的人可以接触到后台系统,我们必须对用户的数据进行加密不仅限于在数据库层面加密存储,前端展示的时候也必须要对例如:手机号,地址,身-份-证号等等隐私数据进行脱敏处理。
实现方式
1.最容易想到的就是利用硬编码的形式,哪些接口中涉及到了隐私数据,我们就去接口中对隐私数据进行脱敏。(ps一开始我确实是这么做的)
2.但是我发现太多太多接口都需要使用用户隐私数据了,我人工一个一个手工改也太不优雅了!我就想到我们能不能在SpringMVC将数据写入response的时候就将他拦截住,然后我实现一个注解,其实这个注解也就是一个标识。我们通过反射对于被这个注解标注的字段进行脱敏处理,然后再写回对象中。
这样不就可以只对响应类中加一个注解,然后所有使用用户敏感数据的接口都直接脱敏了吗,而且我们也可以很方便的改变我们的脱敏策略!!!
代码
hutools工具依赖
最适合中国宝宝体质的中国工具包,虽然网上很多人喷他,但是我个人觉得还是挺好用的,可能是我段位还不够。
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
@Sensitive注解
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.annotaion
* @className: Sensitive
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:36
* @version: 1.0
*/
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
SensitiveDataType type() default SensitiveDataType.PASSWORD;
}
脱敏策略枚举类
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/
public enum SensitiveDataType {
//脱敏数据类型
NAME("name"),
ID_CARD("idCard"),
PHONE("phone"),
EMAIL("email"),
BANK_CARD("bankCard"),
ADDRESS("address"),
PASSWORD("password"),
;
SensitiveDataType(String type) {
this.type = type;
}
@Getter
private String type;
}
响应拦截器
这里就是最核心的代码了,利用了SpringMVC提供的钩子接口,ResponseBodyAdvice接口,其中提供了一个beforeBodyWrite方法,这个方法就可以在数据写入响应前可以对数据进行处理。
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/
@ControllerAdvice
public class SensitiveDataAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 拦截所有响应
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, org.springframework.http.MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response) {
// 如果返回类型是result
if (body instanceof Result<?>){
// 处理对象,进行脱敏操作
handleSensitiveFields((Result<?>) body);
}
return body;
}
private void handleSensitiveFields(Result<?> res) {
Object data = res.getData();
//获取data的下的全部字段
if (data == null) {
return;
}
Field[] fields = data.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断是否有 @SensitiveData 注解
if (field.isAnnotationPresent(Sensitive.class)) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
SensitiveDataType sensitiveDataType = annotation.type();
field.setAccessible(true);
try {
Object value = field.get(data);
if (value instanceof String) {
// 执行脱敏操作
String maskedValue = DesensitizationUtils.maskData((String) value, sensitiveDataType.getType());
field.set(data, maskedValue);
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
}
脱敏工具类
这个工具类依赖于hutools提供的DesensitizedUtil
public class DesensitizationUtils {
public static String maskData(String data, String type) {
if (data == null) {
return null;
}
//使用switch匹配SensitiveDataType枚举中的类型,并且使用hutool脱敏工具类进行脱敏
return switch (type) {
case "name" -> DesensitizedUtil.chineseName(data);
case "idCard" -> DesensitizedUtil.idCardNum(data, 2, data.length() - 2);
case "phone" -> DesensitizedUtil.mobilePhone(data);
case "email" -> DesensitizedUtil.email(data);
case "bankCard"-> DesensitizedUtil.bankCard(data);
case "address" -> DesensitizedUtil.address(data, data.length() - 6);
default -> data;
};
}
}
效果演示
来源:juejin.cn/post/7419148660796293139
Spring Boot + liteflow 居然这么好用!实战
在我们的日常开发中,经常会遇到一些需要串行
或并行
处理的复杂业务流程。
那我们该如何利用Spring Boot
结合liteflow
规则引擎来简化我们的业务流程
先看一个实战案例!!
在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。
这时候,我们可以利用liteflow
进行规则编排,处理这些并行任务。
1. 引入依赖
首先,在pom.xml
文件中添加liteflow
的依赖:
xml
<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>
2. 增加配置
在application.yml
文件中添加liteflow
的配置:
yaml
spring:
application:
name: liteflow-demo
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
3. 编写规则文件
在resources
目录下创建flow.xml
文件,编写规则文件内容:
xml
<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>
4. 编写业务逻辑组件
按照规则文件中的定义,编写相应的业务逻辑组件:
java
@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}
@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}
5. 流程触发
当订单完成后,我们需要触发liteflow
的流程来执行积分发放和消息发送的逻辑。
我们可以在订单完成的服务方法中添加如下代码:
java
@Service
public class OrderService {
@Autowired
private FlowExecutor flowExecutor;
public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());
// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}
在上述代码中,我们使用
FlowExecutor
来执行liteflow
流程,并将订单对象传递给流程。这将触发
flow.xml
中定义的规则,执行并行的积分发放和消息发送逻辑。
性能统计
liteflow
在启动时完成规则解析和组件注册,保证高性能的同时,还能统计各业务环节的耗时,帮助我们进行性能优化。
以下是一个性能统计示例:
java
@LiteflowComponent("performanceNode")
public class PerformanceNode extends NodeComponent {
@Override
public void process() throws Exception {
long start = System.currentTimeMillis();
// 业务逻辑
long end = System.currentTimeMillis();
System.out.println("PerformanceNode execution time: " + (end - start) + "ms");
}
}
liteflow
组件概览
在liteflow
中,主要有以下几种组件:
- 普通组件:集成
NodeComponent
,用于执行具体的业务逻辑;- 选择组件:通过业务逻辑选择不同的执行路径;
- 条件组件:基于条件返回结果,决定下一步的业务流程。
java
// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}
// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}
// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}
EL规则文件
在liteflow
中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。
xml
id="commonNode"/>
id="conditionNode">
id="nextNode"/>
id="otherNode"/>
id="choiceNode">
id="case1" to="node1"/>
id="case2" to="node2"/>
如何使用EL规则文件
- 创建规则文件:将上述规则文件保存为
flow.xml
,放在项目的resources
目录下; - 配置
liteflow
:在Spring Boot项目中添加liteflow
的配置,指定规则文件的位置;
yaml
liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
- 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。
数据上下文
在liteflow
中,数据上下文非常重要,它用于参数传递
和业务逻辑
的执行。
我们可以通过以下代码示例了解数据上下文的用法。
java
@LiteflowComponent("contextNode")
public class ContextNode extends NodeComponent {
@Override
public void process() throws Exception {
// 获取数据上下文
LiteflowContext context = this.getContextBean();
// 设置数据
context.setData("key", "value");
// 获取数据
String value = context.getData("key");
System.out.println("Context data: " + value);
}
}
配置详解
在使用liteflow
时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。
以下是一个配置示例。
yaml
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
总的来说,liteflow
在简化业务流程管理方面起到了非常重要的作用,可以提升开发效率和业务流程管理能力。
来源:juejin.cn/post/7388033492570095670
只CURD的Java后端要如何提升自己?
你是否工作3~5年后,发现日常只做了CURD的简单代码。
你是否每次面试就会头疼,自己写的代码,除了日常CURD简历上毫无亮点可写
抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情我们是可以做的更好的。
于是有了这篇文章。
小北将带大家从六个方面深入探讨如何在Java后台管理开发中不断进步,帮助你在职业道路上稳步前行
一、写优雅的代码
优雅代码的重要性
优雅的代码不仅易于阅读和维护,还能减少错误,提高开发效率。对于后台管理系统,代码的整洁与规范尤为重要,因为它们通常涉及复杂的业务逻辑和大量的数据处理。
我们看一个简单的案例,我们直观的感受下,需求如下:
用户可以通过银行网页转账给另一个账号,支持跨币种转账。
同时因为监管和对账需求,需要记录本次转账活动
拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:
1、从MySql数据库中找到转出和转入的账户,选择用MyBatis的mapper实现DAO
2、从Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是http开放接口)
3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限
4、实现转入和转出操作,扣除手续费,保存数据库
5、发送Kafka审计消息,以便审计和对账用
而一个常规的代码实现如下:
public class TransferServiceImpl implements TransferService {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);
// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}
// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}
if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}
// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);
// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);
// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);
return Result.success(true);
}
}
我们可以看到,一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。
在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。
那么优雅的代码应该是什么样的?
public class TransferServiceImplNew implements TransferService {
// 可以看出来,经过重构后的代码有以下几个特征:
// 业务逻辑清晰,数据存储和业务逻辑完全分隔。
// Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,
// 但是却包含了所有核心业务逻辑,可以单独完整测试。
// 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,
// 所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。
// 我们可以根据新的结构重新画一张图:
private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
// 获取汇率
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(
sourceAccount.getCurrency(), targetMoney.getCurrency()
);
// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);
return Result.success(true);
}
}
虽然功能都一样,但是在面试的时候写了上面的代码能得到了面试官的赞扬,而如果写成了上面的样子,估计不会有这种效果。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
二、提升代码质量
如果说优雅的代码是我们程序员的里子,那代码质量就是我们的面子。
想象一下,如果你写的代码,提测后测试出来各种bug,上线后也出现bug,就算你代码写的再优雅也没用了。
如何提升代码质量
想提升代码质量,最理想的是靠 code review,但是实际上这玩意在大多数公司根本就推行不下去。
为什么呢?因为大家都很忙,忙着改上一个迭代的bug,忙着写下一个迭代的需求,忙着做各种性能优化,忙着做各种日报、周报、月报等等...
所以靠人不如靠己,我们在日常工作中要善于利用工具,来帮我们发现问题,解决问题。
例如以下实践方法:
- 自动化测试:编写单元测试、集成测试,确保代码功能的正确性和稳定性。使用JUnit、Mockito等工具进行测试驱动开发(TDD)。
- 持续集成(CI):通过Jenkins、GitHub Actions等工具,自动化构建和测试流程,及时发现并解决问题。
- 静态代码分析:使用工具如SonarQube,对代码进行静态分析,检测代码中的潜在问题和代码风格违规。
- 合理利用大模型,对我们的代码进行分析,发现bug。
三、关注业务
看到这里有的人不禁要问,我一个后端开发,写好代码就行了,还需要关注业务吗?
如果你有这样的想法,那就大错特错了。
中国的企业,90%的开发都是面向业务开发,纯做研究的公司少之又少。所以你想要在互联网行业走的更高,那就一定不能脱离业务。
而且只有深刻理解业务了,才能对系统有一个整体的规划意识,才能设计出一个好的系统。
实践方法
- 多与业务团队沟通:定期与产品经理、业务分析师沟通,了解业务流程和需求变化。
- 参与需求讨论:积极参与需求评审和讨论,提出技术上的可行性建议和优化方案。
- 业务文档学习:阅读业务相关的文档和资料,全面了解系统的功能和使用场景。
- 业务架构梳理:梳理公司整体系统业务领域架构图,先从整体对公司业务有一个清晰的概念
实践建议
- 业务流程图:绘制业务流程图,帮助理解各个业务环节之间的关系和数据流动。
- 用户故事:通过用户故事的方式,站在用户角度思考功能设计,提高系统的用户体验。
- 持续学习:随着业务的发展,持续学习和更新业务知识,确保技术方案与业务需求保持一致。
四、培养架构思维
5年以上的程序员,就一定要培养自己的架构思维了,也就是要把自己的技术视角由自己的点扩展到线,再扩展到面。
从而对公司整体系统技术架构有一个整体的认知。
例如到一个公司之后,你一定要具有自我绘制如下技术架构图的能力。
架构思维的重要性
良好的架构设计是系统稳定、高效运行的基础。
培养架构思维,能够帮助你在项目初期做出合理的技术选型和系统设计,提升系统的可扩展性和维护性。
实践方法
- 学习架构设计原则:如单一职责原则(SRP)、开闭原则(OCP)、依赖倒置原则(DIP)等,指导架构设计。
- 分层架构:采用DDD领域分层架构,如适配层、应用层和领域层、防腐层,明确各层的职责,降低耦合度。
- 模块化设计:将系统拆分为独立的领域模块或微服务,提升系统的可维护性和可扩展性。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
五、关注源码
源码学习的价值
其实学习源码最市侩的价值那就是面试会问了,比如说 HashMap 的一些经典问题:
1、加载因子为什么是 0.75?
2、为什么链表改为红黑树的阈值是 8?
3、HashMap的底层数据结构是什么?
4、解决hash冲突的办法有哪些?
5、HashMap数组的长度为什么是 2 的幂次方?
6、HashMap 的扩容方式?
这些问题只有通过源码才能得出比较准确的回答。
但是我个人认为阅读源码对我们最大的价值其实是我们可以学习借鉴源码设计中的优秀思想。
想象一下,我们每天做着CURD的996工作,根本没有机会接触优秀的项目设计思想。而阅读源码是我们最容易接触到优秀项目设计核心思想的机会。
其次阅读源码也可以在系统出现棘手的问题时候,可以快速定位解决。大大提升自己在职场中的核心竞争力。
有个同学说过一句话,给我的印象特别深刻,就是“有啥解决不了的?只要你肯阅读源码。”
六、项目管理能力
实现一个软件系统的过程,不仅只有编码,还涉及到项目安排,团队协调等一系列非技术因素,如果想从一名程序员走向管理岗,成为 team leader 或者开发经理,软件工程方面的知识就必须得跟得上。
要想写出一个好而美的程序,需要经过三个阶段。
第一阶段:有扎实的基本功,简单点说,就是要做到语法熟练、框架熟练,成为一名能够完成开发任务的“码农”。
第二阶段:从“码农”到“工程师”,在局部上,不仅要能够实现功能,还能关注功能之外的维度,比如健壮性、低耦合、可扩展等指标。
第三阶段:从“工程师”到“架构师”,不仅在局部上追求一个模块的好坏,而且还要从整个系统层面去掌控,合理安排资源的优先级,保证整个系统不会出现腐败等等。
所以要想成为一名优秀的架构师,项目管理能力是必不可少的。
比如项目范围管理、质量管理、资源/成本管理、风险管理等一系列管理能力。有兴趣的同学可以学习PMP,提升一下自己的项目管理能力。
传统预测项目管理
敏捷开发项目管理
说在最后
学习的过程,就好像登山一样,大概有 80% 的人在这个过程中会掉队。要想成为一名优秀的架构师,除了自身的努力,也需要一点点运气。
那么请相信我,只要目标明确,努力加上坚持,再加上一点点好运气,你就能登顶!
免费看 500 套技术教程的网站,希望对你有帮助
*最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的, *[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
求一键三连:点赞、分享、收藏
我的技术网站:cxykk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,全部免费,欢迎收藏和转发。
来源:juejin.cn/post/7418061055228215322
买了个mini主机当服务器
虽然有苹果的电脑,但是在安装一些软件的时候,总想着能不能有一个小型的服务器,免得各种设置导致 Mac 出现异常。整体上看了一些小型主机,也看过苹果的 Mac mini,但是发现它太贵了,大概要 3000 多,特别是如果要更高配置的话,价格会更高,甚至更贵。所以,我就考虑一些别的小型主机。也看了一些像 NUC 这些服务器,但是觉得还是太贵了。于是我自己去淘宝搜索,找到了这一款 N100 版的主机。
成本的话,由于有折扣,所以大概是 410 左右,然后自己加了个看上去不错的内存条花了 300 左右。硬盘的话我自己之前就有,所以总成本大概是 700 左右。大小的话,大概是一台手机横着和竖着的正方形大小,还带 Wi-Fi,虽然不太稳定。
一、系统的安装
系统我看是支持windows,还有现在Ubuntu,但是我这种选择的是centos stream 9, 10的话我也找过,但是发现很多软件还有不兼容。所以最终还是centos stream 9。
1、下载Ventoy软件
去Ventoy官网下载Ventoy软件(Download . Ventoy)如下图界面
2、制作启动盘
选择合适的版本以及平台下载好之后,进行解压,解压出来之后进入文件夹,如下图左边所示,双击打开Ventoy2Disk.exe,会出现下图右边的界面,选择好自己需要制作启动盘的U盘,然后点击安装等待安装成功即可顺利制作成功启动U盘。
3、centos安装
直接取官网,下载完放到u盘即可。
它的BIOS是按F7启动,直接加载即可。
之后就是正常的centos安装流程了。
二、连接wifi
因为是用作服务器的,所以并没有给它配置个专门的显示器,只要换个网络,就连不上新的wifi了,这里可以用网线连接路由器进行下面的操作即可。
在 CentOS 系统中,通过命令行连接 Wi-Fi 通常需要使用 nmcli(NetworkManager 命令行工具)来管理网络连接。nmcli 是 NetworkManager 的一个命令行接口,可以用于创建、修改、激活和停用网络连接。以下是如何使用 nmcli 命令行工具连接 Wi-Fi 的详细步骤。
步骤 1: 检查网络接口
首先,确认你的 Wi-Fi 网络接口是否被检测到,并且 NetworkManager 是否正在运行。
nmcli device status
输出示例:
DEVICE TYPE STATE CONNECTION
wlp3s0 wifi disconnected --
enp0s25 ethernet connected Wired connection 1
lo loopback unmanaged --
在这个示例中,wlp3s0 是 Wi-Fi 接口,它当前处于未连接状态。
步骤 2: 启用 Wi-Fi 网卡
如果你的 Wi-Fi 网卡是禁用状态,可以通过以下命令启用:
nmcli radio wifi on
验证 Wi-Fi 是否已启用:
nmcli radio
步骤 3: 扫描可用的 Wi-Fi 网络
使用 nmcli 扫描附近的 Wi-Fi 网络:
nmcli device wifi list
你将看到可用的 Wi-Fi 网络列表,每个网络都会显示 SSID(网络名称)、安全类型等信息。
步骤 4: 连接到 Wi-Fi 网络
使用 nmcli 命令连接到指定的 Wi-Fi 网络。例如,如果你的 Wi-Fi 网络名称(SSID)是 MyWiFiNetwork,并且密码是 password123,你可以使用以下命令连接:
nmcli device wifi connect 'xxxxxx' password 'xxxxx'
你应该会看到类似于以下输出,表明连接成功:
Device 'wlp3s0' successfully activated with 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.
步骤 5: 验证连接状态
验证网络连接状态:
nmcli connection show
查看当前连接的详细信息:
nmcli device show wlp3s0
三、VNC远程连接
桌面还是偶尔需要用一下的,虽然用的不多。
root@master:~# dnf install -y tigervnc-server
root@master:~# vncserver
bash: vncserver: command not found...
Install package 'tigervnc-server' to provide command 'vncserver'? [N/y] y
* Waiting in queue...
* Loading list of packages....
The following packages have to be installed:
dbus-x11-1:1.12.20-8.el9.x86_64 X11-requiring add-ons for D-BUS
tigervnc-license-1.14.0-3.el9.noarch License of TigerVNC suite
tigervnc-selinux-1.14.0-3.el9.noarch SELinux module for TigerVNC
tigervnc-server-1.14.0-3.el9.x86_64 A TigerVNC server
tigervnc-server-minimal-1.14.0-3.el9.x86_64 A minimal installation of TigerVNC server
Proceed with changes? [N/y] y
* Waiting in queue...
* Waiting for authentication...
* Waiting in queue...
* Downloading packages...
* Requesting data...
* Testing changes...
* Installing packages...
WARNING: vncserver has been replaced by a systemd unit and is now considered deprecated and removed in upstream.
Please read /usr/share/doc/tigervnc/HOWTO.md for more information.
You will require a password to access your desktops.
getpassword error: Inappropriate ioctl for device
Password:
之后在mac开启屏幕共享就可以了
四、docker 配置
docker安装我以为很简单,没想到这里是最难的一步了。安装完docker之后,总是报错:
Error response from daemon: Get "https://registry-1.docker.io/v2/": context deadline exceeded
即使改了mirrors也毫无作用
{
"registry-mirrors": [
"https://ylce84v9.mirror.aliyuncs.com"
]
}
看起来好像是docker每次pull镜像都要访问一次registry-1.docker.io,但是这个网址国内已经无法连接了,各种折腾,这里只贴一下代码吧,原理就就不讲了(懂得都懂)。
sslocal -c /etc/猫代理.json -d start
curl --socks5 127.0.0.1:1080 http://httpbin.org/ip
sudo yum -y install privoxy
vim /etc/systemd/system/docker.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:8118"
/etc/systemd/system/docker.service.d/https-proxy.conf
[Service]
Environment="HTTPS_PROXY=http://127.0.0.1:8118"
最后重启docker
systemctl start privoxy
systemctl enable privoxy
sudo systemctl daemon-reload
sudo systemctl restart docker
五、文件共享
sd卡好像读取不了,只能换个usb转换器
fdisk -l
mount /dev/sdb1 /mnt/usb/sd
在CentOS中设置文件共享,可以使用Samba服务。以下是配置Samba以共享文件的基本步骤:
- 安装Samba
sudo yum install samba samba-client samba-common
- 设置共享目录
编辑Samba配置文件
/etc/samba/smb.conf
,在文件末尾添加以下内容:
[shared]
path = /path/to/shared/directory
writable = yes
browseable = yes
guest ok = yes
- 设置Samba密码
为了允许访问,需要为用户设置一个Samba密码:
sudo smbpasswd -a your_username
- 重启Samba服务
sudo systemctl restart smb.service
sudo systemctl restart nmb.service
- 配置防火墙(如果已启用)
允许Samba通过防火墙:
sudo firewall-cmd --permanent --zone=public --add-service=samba
sudo firewall-cmd --reload
现在,您应该能够从网络上的其他计算机通过SMB/CIFS访问共享。在Windows中,你可以使用\\centos-ip\shared
,在Linux中,你可以使用smbclient //centos-ip/shared -U your_username
参考:
https://猫代理help.github.io/猫代理/linux.html
来源:juejin.cn/post/7430460789067055154
three 写一个溶解特效,初探 three 着色系统
背景
溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 three.js 的着色器系统。
原理
使用一张噪波图,根据时间动态改变进度 progress
, 根据这个值与噪波图数值做比较,决定使用过渡色还是舍弃当前片元。
过渡色
为了使用过渡色,我们定义一个作用范围变量 edgeWidth
用来表示当前进度和 噪波数值(noiseValue)
之间的区域,这个区域填充 过渡色(edgeColor)
。
变化速度
progress
的变化通过变化速度(DissolveSpeed)
来控制。
类型
溶解可以分为 出现和消失 两种类型,两种类型可以互相转换,我们可以通过判断 progress
的边界来重新设置 progress
的增加量符号(加号变减号,减号变加号),并重新设置 progress
的值等于 0 || 1
来重新设置变化边界。
原理讲完了,接下来进入实践。
实践
先从最简单的 wavefront
格式说起,再拓展到其他更通用模型或者材质的用法。
波前 wavefront 格式
作为 3D 模型最早的格式之一,.obj
后缀的格式是由 wavefront 公司开发的,由于容易和其他常见类型的文件比如 gcc 编译的过程文件 .obj
混淆,将其表述为 wavefront
模型格式。
对于这个格式来说,几何数据和材质数据是分开加载的,你需要先加载 .obj
格式的文件,然后再去加载材质数据文件 .mtl
。对于我们的示例来说是需要使用 ShaderMaterial
来自定义着色效果,因而我们直接加载对应的 材质贴图 做原理展示,就不使用 .mtl
的加载器了。
需要做的其实只有两步:
- 读取的模型后用
Geometry
和ShaderMaterial
创建新的Mesh
。
- 读取的模型后用
ShaderMaterial
的unifroms.progress
在requestAnimationFrame
里做更新。
直接来看下着色器怎么写:
顶点着色器:
let vertexShader = /* glsl */`
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
主要是定义了 vUv
这个可传递变量,为了把内置的纹理坐标传递到 fragmentShader
。
片元着色器:
let fragShader = /* glsl */`
uniform float progress;
uniform float edgeWidth;
uniform vec3 edgeColor;
uniform sampler2D mainTexture;
uniform sampler2D noiseTexture;
varying vec2 vUv;void main(void){
vec4 originalColor = texture2D(mainTexture, vUv);
float noiseValue = texture2D(noiseTexture, vUv).r;
vec4 finalColor = originalColor;
if(noiseValue > progress)
{
discard;
}
if(noiseValue + edgeWidth > progress){
finalColor = vec4(edgeColor, 1.0);
}
gl_FragColor = finalColor;
}
`;
其中 originColor
是原始材质贴图,类型是 vec4
,noiseValue
是读取的噪波贴图取 r
通道的值,事实上,噪波图是灰度图,所以取 rgb
任意通道的都可以。然后对于 noiseValue
,随着 progress
逐渐增大,小于 progress
数值的噪波片元越来越少,模型出现。下面那句 + edgeWidth
则是把 edgeColor
填充到里面,原理是一样的。最后输出颜色。
这是出现的逻辑,如果是要消失呢?控制下边界条件就可以了:
function render() {
requestAnimationFrame(render);
controller.update();
// 出现
if (uniforms.progress.value < 0) {
uniforms.progress = 0;
stride = dissolveSpeed;
}
// 消失
if (uniforms.progress.value > 1) {
uniforms.progress = 1;
stride = -dissolveSpeed;
}
uniforms.progress.value += stride;
renderer.render(scene, camera);
}
效果立竿见影:
再想一遍
写着色器和通用程序不大一样,单纯按上面这么讲可能不是很清晰,我们更深度地分析下,培养一下 rgb
思维。
已知出现和消失是互为逆过程,通过 CPU
端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。
按照上面说的,我们关注,比如就 出现 的状态吧,边界条件是 阈值 和 噪波值 的比较结果。也就是 progress
和 noiseValue
。
用 Exclidraw 画下示意图:
考虑 出现 的情况,剩余的进度或者叫阈值(越来越小), 与当前片元噪声值比较大小,如果更大则舍弃掉表示还没出现的部分;与当前值往前剪掉的部分比较,如果更大则使用这个过渡色;其他情况是已经出现的部分,直接保留就可以了。
写成代码:
void main() {
...
float restProgress = 1.0 - dissolveProgress;
if(noiseValue < restProgress) {
discard;
}
if(noiseValue - edgeWidth < restProgress ) {
gl_FragColor = finalColor;
}
...
}
反向来思考,随着阈值增加,出现的图像越来多,往前减掉过渡值(edgeWidth
), 这部分呈现过渡色;小于当前 noiseValue
的部分舍弃,是还没出现的部分。
写成代码:
void main() {
...
if(noiseValue > dissolveProgress)
{
discard;
}
if(noiseValue + edgeWidth > dissolveProgress){
gl_FragColor = vec4(edgeColor, 1.0);
}
...
}
这样,我们就用两种等价的方法实现了同一效果,后面的章节我们使用 glsl
函数把 条件判断 语句去掉。
这里其实叫 edgeWidth
有歧义,换成 edgeThickness
可能比较符合,如果这个值过大,就会超出变化范围出现异常,所以还是要把其限制在一个比较小的范围,这里为了调试先让它最大值等于 1。
edgeWidth
值过大:
其他格式
我们拿更常用的其他格式来研究一下。通常 web
端会使用 gltf, fbx
等通用格式,我们这里拿 web
端最通用的 gltf
格式模型来说明,其他通用模型类型道理一样。
对于 gltf
格式来说,加载完模型就赋予了材质,可能的类型有 MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial
等,我用封面的士兵模型,使用的是 MeshStandardMaterial
类型的材质,接下来看如何修改内置着色器而实现效果。
ShaderChunk 和 ShaderLib
来看下 three
的目录,较新版本的 three
把核心代码安排在 src
目录下,/examples/jsm
目录下则是以 插件addons
的形式引入的额外功能,比如 GLTFLoader
之类比较通用的功能。而内部着色器的实现在 src/renderers/shaders
目录下:
我们直接打开 ShaderLib.js
文件找下模型使用的 MeshStandardMaterial
的定义:
可以看到是复用了 meshphysical
的着色器,这对着色器还在 MeshPhysicalMaterial
材质里被使用,通过材质类定义的 defines
字段来开启相应的计算,这样的做法使得 MeshStandardMaterial
作为 MeshPhysicalMaterial
的回退选项。到 ShaderChunk
目录下打开 meshphysical.glsl.js
看下宏定义:
OK,已经了解了材质定义和对应着色器的关系了,接下来就是如何把我们的逻辑加到相应着色器字符串里了。
onBeforeCompile
官方文档约等于没写,还是去看 examples
的代码吧,关键字 onBeforeCompile
搜索下:
右下角点进去看代码:
这下就明白了,顾名思义,这个函数可以在编译着色器程序之前允许我们插入自己的代码, 我们可以根据功能对相应模块进行覆写或者添加功能,我们不希望修改修改默认着色器的内容,直接把溶解效果加到最后,接下来看下怎么做。
调试
按照这个做法,非常依赖 javascript
的 replace
方法,我们需要小心操作,经过实验,把所有代码放到同一串里是没问题的,这里需要反复打印调试,如果有问题请使用双引号来使用原始字符串。
如果没有处理格式,直接塞进去不会对齐的,很好辨认:
接下来直接移植代码:
看到注释的那句话了吗,如果注释掉,并把阈值开到最大覆盖全部范围,可以明显看到和设置的颜色不一样,原因是因为之前的 shader
代码处理结果是转化到线性输出显示的,我们在标准着色器最后处理,一样要做线性转化。这个线性转化的意思是 gamma
变换的逆变换, gamma
变换是由于人眼对于颜色的感知非线性,非线性的原因和视锥细胞,视杆细胞数量比例不一样有关,省略一万字,大家有兴趣自己去搜~
没有线性转换:
线性转换后颜色就正常了:
拓展
再换一种写法
之前我们用直接舍弃片元的方法来实现过渡,接下来我们使用更 shader
风格的写法来重写,因为这个效果显示和消失具有二值性(要么有颜色要么透明),可以用 step(x,y)
函数来写,这个函数比较 y > x
的结果,true
则返回 1,否则返回 0 , 正好可以来表达透明度。
看代码,只有 fragmentShader
不一样:
这里的想法是先控制是否显示颜色,找的边界就是 noiseValue - edgeWidth
,然后再判断使用原来的像素或者过渡色,如果大于 noiseValue
使用原来的像素,否则使用过渡的颜色,然后 mix
函数这里的第三个变量刚好是 step
函数的结果,所以就可以切换这两颜色了。
哦对,记得设置这个 material.transparent = true;
,否则会使用默认的混合颜色白色:
整活
昨天在沸点发了两张图,其实很简单,到这里把过渡色换成贴图采样就行了,比如这样:
学会了吗?赶紧搬到项目里惊艳领导吧。
思考
- 能否和环境做交互?
更新
在线 Demo: wwjll.github.io/three-pract…
写文章不易,点赞收藏是最好的支持~
来源:juejin.cn/post/7344958089429254182
别再手动拼接 SQL 了,MyBatis 动态 SQL 写法应有尽有,建议收藏!
1.Mybatis 动态 sql 是做什么的?
Mybatis 动态 sql 可以让我们在 Xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。
2.Mybatis 的 9 种 动 态 sql 标 签有哪些?
3.动态 sql 的执行原理?
原理为:使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。
MyBatis标签
1.if标签:条件判断
MyBatis if 类似于 Java 中的 if 语句,是 MyBatis 中最常用的判断语句。使用 if 标签可以节省许多拼接 SQL 的工作,把精力集中在 XML 的维护上。
1)不使用动态sql
<select id="selectUserByUsernameAndSex"
resultType="user" parameterType="com.ys.po.User">
<!-- 这里和普通的sql 查询语句差不多,对于只有一个参数,后面的 #{id}表示占位符,里面 不一定要写id,
写啥都可以,但是不要空着,如果有多个参数则必须写pojo类里面的属性 -->
select * from user where username=#{username} and sex=#{sex}
</select>
if 语句使用方法简单,常常与 test 属性联合使用。语法如下:
<if test="判断条件"> SQL语句</if>
2)使用动态sql
上面的查询语句,我们可以发现,如果 #{username}
为空,那么查询结果也是空,如何解决这个问题呢?使用 if 来判断,可多个 if 语句同时使用。
以下语句表示为可以按照网站名称(name)或者网址(url)进行模糊查询。如果您不输入名称或网址,则返回所有的网站记录。但是,如果你传递了任意一个参数,它就会返回与给定参数相匹配的记录。
<select id="selectAllWebsite" resultMap="myResult">
select id,name,url from website
where 1=1
<if test="name != null">
AND name like #{name}
</if>
<if test="url!= null">
AND url like #{url}
</if>
</select>
2.where+if标签
where、if同时使用可以进行查询、模糊查询
注意,
<if>
失败后,<where>
关键字只会去掉库表字段赋值前面的and,不会去掉语句后面的and关键字,即注意,<where>
只会去掉<if>
语句中的最开始的and关键字。所以下面的形式是不可取的
<select id="findQuery" resultType="Student">
<include refid="selectvp"/>
<where>
<if test="sacc != null">
sacc like concat('%' #{sacc} '%')
</if>
<if test="sname != null">
AND sname like concat('%' #{sname} '%')
</if>
<if test="sex != null">
AND sex=#{sex}
</if>
<if test="phone != null">
AND phone=#{phone}
</if>
</where>
</select>
这个“where”标签会知道如果它包含的标签中有返回值的话,它就插入一个‘where’。此外,如果标签返回的内容是以AND 或OR 开头的,则它会剔除掉。
3.set标签
set可以用来修改
<update id="upd">
update student
<set>
<if test="sname != null">sname=#{sname},</if>
<if test="spwd != null">spwd=#{spwd},</if>
<if test="sex != null">sex=#{sex},</if>
<if test="phone != null">phone=#{phone}</if>
sid=#{sid}
</set>
where sid=#{sid}
</update>
4.choose(when,otherwise) 语句
有时候,我们不想用到所有的查询条件,只想选择其中的一个,查询条件有一个满足即可,使用 choose 标签可以解决此类问题,类似于 Java 的 switch 语句
<select id="selectUserByChoose" resultType="com.ys.po.User" parameterType="com.ys.po.User">
select * from user
<where>
<choose>
<when test="id !='' and id != null">
id=#{id}
</when>
<when test="username !='' and username != null">
and username=#{username}
</when>
<otherwise>
and sex=#{sex}
</otherwise>
</choose>
</where>
</select>
也就是说,这里我们有三个条件,id、username、sex,只能选择一个作为查询条件
- 如果 id 不为空,那么查询语句为:
select * from user where id=?
- 如果 id 为空,那么看username 是否为空,如果不为空,那么语句为
select * from user where username=?;
- 如果 username 为空,那么查询语句为
select * from user where sex=?
5.trim
trim标记是一个格式化的标记,可以完成set或者是where标记的功能
①、用 trim 改写上面第二点的 if+where 语句
<select id="selectUserByUsernameAndSex" resultType="user" parameterType="com.ys.po.User">
select * from user
<!-- <where>
<if test="username != null">
username=#{username}
</if>
<if test="username != null">
and sex=#{sex}
</if>
</where> -->
<trim prefix="where" prefixOverrides="and | or">
<if test="username != null">
and username=#{username}
</if>
<if test="sex != null">
and sex=#{sex}
</if>
</trim>
</select>
- prefix:前缀
- prefixoverride:去掉第一个and或者是or
②、用 trim 改写上面第三点的 if+set 语句
<!-- 根据 id 更新 user 表的数据 -->
<update id="updateUserById" parameterType="com.ys.po.User">
update user u
<!-- <set>
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex}
</if>
</set> -->
<trim prefix="set" suffixOverrides=",">
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex},
</if>
</trim>
where id=#{id}
</update>
- suffix:后缀
- suffixoverride:去掉最后一个逗号(也可以是其他的标记,就像是上面前缀中的and一样)
③、trim+if同时使用可以添加
<insert id="add">
insert int0 student
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="sname != null">sname,</if>
<if test="spwd != null">spwd,</if>
<if test="sex != null">sex,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="sname != null">#{sname},</if>
<if test="spwd != null">#{spwd},</if>
<if test="sex != null">#{sex},</if>
<if test="phone != null">#{phone}</if>
</trim>
</insert>
6.MyBatis foreach标签
foreach是用来对集合的遍历,这个和Java中的功能很类似。通常处理SQL中的in语句。
foreach 元素的功能非常强大,它允许你指定一个集合,声明可以在元素体内使用的集合项(item)和索引(index)变量。它也允许你指定开头与结尾的字符串以及集合项迭代之间的分隔符。这个元素也不会错误地添加多余的分隔符
你可以将任何可迭代对象(如 List、Set 等)、Map 对象或者数组对象作为集合参数传递给 foreach。当使用可迭代对象或者数组时,index 是当前迭代的序号,item 的值是本次迭代获取到的元素。当使用 Map 对象(或者 Map.Entry 对象的集合)时,index 是键,item 是值。
//批量查询
<select id="findAll" resultType="Student" parameterType="Integer">
<include refid="selectvp"/> WHERE sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</select>
//批量删除
<delete id="del" parameterType="Integer">
delete from student where sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</delete>
整合案例
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yzx.mapper.StuMapper">
<sql id="selectvp">
select * from student
</sql>
<select id="find" resultType="Student">
<include refid="selectvp"/>
</select>
<select id="findbyid" resultType="student">
<include refid="selectvp"/>
WHERE 1=1
<if test="sid != null">
AND sid like #{sid}
</if>
</select>
<select id="findQuery" resultType="Student">
<include refid="selectvp"/>
<where>
<if test="sacc != null">
sacc like concat('%' #{sacc} '%')
</if>
<if test="sname != null">
AND sname like concat('%' #{sname} '%')
</if>
<if test="sex != null">
AND sex=#{sex}
</if>
<if test="phone != null">
AND phone=#{phone}
</if>
</where>
</select>
<update id="upd">
update student
<set>
<if test="sname != null">sname=#{sname},</if>
<if test="spwd != null">spwd=#{spwd},</if>
<if test="sex != null">sex=#{sex},</if>
<if test="phone != null">phone=#{phone}</if>
sid=#{sid}
</set>
where sid=#{sid}
</update>
<insert id="add">
insert int0 student
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="sname != null">sname,</if>
<if test="spwd != null">spwd,</if>
<if test="sex != null">sex,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="sname != null">#{sname},</if>
<if test="spwd != null">#{spwd},</if>
<if test="sex != null">#{sex},</if>
<if test="phone != null">#{phone}</if>
</trim>
</insert>
<select id="findAll" resultType="Student" parameterType="Integer">
<include refid="selectvp"/> WHERE sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</select>
<delete id="del" parameterType="Integer">
delete from student where sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</delete>
</mapper>
测试类:
package com.yzx.test;
import com.yzx.entity.Student;
import com.yzx.mapper.StuMapper;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
public class StuTest {
SqlSession sqlSession=null;
InputStream is=null;
@Before
public void before() throws IOException {
//1.读取核心配置文件
is= Resources.getResourceAsStream("sqlMapperConfig.xml");
//2.拿到工厂构建类
SqlSessionFactoryBuilder sqlSessionFactoryBuilder=new SqlSessionFactoryBuilder();
//3.拿到具体工厂
SqlSessionFactory build=sqlSessionFactoryBuilder.build(is);
//4.拿到session
sqlSession = build.openSession();
}
@After
public void after(){
//7,提交事务
sqlSession.commit();
//8.关闭资源
sqlSession.close();
if(is!=null){
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
};
}
//查询所有
@Test
public void find(){
//5.获取具体的mapper接口
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
//6.调用执行
List<Student> list=mapper.find();
list.forEach(a-> System.out.println(a));
}
//查询单个
@Test
public void findbyid(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
List<Student> list=mapper.findbyid(2);
list.forEach(a-> System.out.println(a));
}
//模糊查询
@Test
public void findQuery(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSname("小");
stu.setSex("男");
List<Student> list=mapper.findQuery(stu);
list.forEach(a-> System.out.println(a));
}
//修改
@Test
public void upd(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSid(3);
stu.setSname("小若");
stu.setSex("人妖");
int i=mapper.upd(stu);
System.out.println("修改了"+i+"条数据"+" "+stu.toString());
}
//添加
@Test
public void add(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSname("小贺");
stu.setSex("男");
stu.setPhone("99999999");
int i=mapper.add(stu);
System.out.println("添加了"+i+"条数据"+" "+stu.toString());
}
//批量操作
@Test
public void findAll(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Integer[] i={1,2,3,4};
List<Student> list=mapper.findAll(i);
list.forEach(a-> System.out.println(a));
}
//批量操作
//批量删除
@Test
public void del(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Integer[] i={1,2,3,4};
int i1=mapper.del(i);
System.out.println("删除了"+i1+"条数据");
}
}
7.sql
在实际开发中会遇到许多相同的SQL,比如根据某个条件筛选,这个筛选很多地方都能用到,我们可以将其抽取出来成为一个公用的部分,这样修改也方便,一旦出现了错误,只需要改这一处便能处处生效了,此时就用到了<sql>
这个标签了。
当多种类型的查询语句的查询字段或者查询条件相同时,可以将其定义为常量,方便调用。为求<select>
结构清晰也可将 sql 语句分解。
<sql id="selectvp">
select * from student
</sql>
8.include
这个标签和<sql>
是天仙配,是共生的,include用于引用sql标签定义的常量。比如引用上面sql标签定义的常量
refid这个属性就是指定<sql>
标签中的id值(唯一标识)
<select id="findbyid" resultType="student">
<include refid="selectvp"/>
WHERE 1=1
<if test="sid != null">
AND sid like #{sid}
</if>
</select>
9.如何引用其他XML中的SQL片段
比如你在com.xxx.dao.xxMapper
这个Mapper的XML中定义了一个SQL片段如下:
<sql id="Base_Column_List"> ID,MAJOR,BIRTHDAY,AGE,NAME,HOBBY</sql>
此时我在com.xxx.dao.PatinetMapper
中的XML文件中需要引用,如下:
<include refid="com.xxx.dao.xxMapper.Base_Column_List"></include>
MyBatis关联查询
1.MyBatis一对多关联查询
<!--一对多-->
<resultMap id="myStudent1" type="student1">
<id property="sid" column="sid"/>
<result property="sname" column="sname"/>
<result property="sex" column="sex"/>
<result property="sage" column="sage"/>
<collection property="list" ofType="teacher">
<id property="tid" column="tid"/>
<result property="tname" column="tname"/>
<result property="tage" column="tage"/>
</collection>
</resultMap>
<!--一对多-->
<select id="find1" resultMap="myStudent1">
select * from student1 s left join teacher t on s.sid=t.sid
</select>
2.MyBatis多对一关联查询
<!--多对一-->
<resultMap id="myTeacher" type="teacher">
<id property="tid" column="tid"/>
<result property="tname" column="tname"/>
<result property="tage" column="tage"/>
<association property="student1" javaType="Student1">
<id property="sid" column="sid"/>
<result property="sname" column="sname"/>
<result property="sex" column="sex"/>
<result property="sage" column="sage"/>
</association>
</resultMap>
<!--多对一-->
<select id="find2" resultMap="myTeacher">
select * from teacher t right join student1 s on t.sid=s.sid
</select>
3.MyBatis多对多关联查询
<!--多对多 以谁为主表查询的时候,主表约等于1的一方,另一方相当于多的一方-->
<select id="find3" resultMap="myStudent1">
select * from student1 s left join relevance r on s.sid=r.sid left join teacher t on r.tid=t.tid
</select>
最后说一句(求关注!别白嫖!)
如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。
关注公众号:woniuxgg,在公众号中回复:笔记 就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!
来源:juejin.cn/post/7382394009199034387
手把手使用Blender+ThreeJS制作跨栏小游戏
效果展示
- 先录制的视频,再转化为GIF图片导致展示效果有点延迟,实际效果还是挺丝滑的,感兴趣的可以上手尝试一下
人物模型和动画获取
- 在mixamo.com网站,需要先登录一下,可以直接使用Google邮箱登录,然后来到Characters页,下方有100多种人物模型,点击左边卡片可以选择自己喜欢的人物模型,或者如下图和我选的一样
- 然后来到Animations页,默认如下图红框内展示的也是刚才我们选择的人物模型,如果右侧展示不对,需要回到Characters页重新选择人物模型
- 因为动画比较多,这里我们直接在左上角搜索框内搜索自己想要的动作动画即可,首先搜索Idle,我这里使用的动画是Happy Idle,还将右侧的Overdrive的值从50调整到75,值越大动画频率越快,调整好后直接点击右上方的DOWNLOAD
- 弹出的弹窗里的内容都不想要修改,直接点击如下图右下角的DOWNLOAD,等待一会后,选择本地文件夹下载即可
- 接着左上角搜索Running,选择Running卡片,并且勾选右侧的In Place,让人物模型在原地跑动;如果不勾选的话,因为动画本身带有位移,会影响我们使用ThreeJS改变人物模型的position控制人物位移的;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 继续左上角搜索Jump,选择Jump卡片,并且勾选右侧的In Place,让人物模型在原地跑动;设置好后直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 继续左上角搜索Death,选择Falling Back Death卡片,这个动画不需要其他调整,直接点击DOWNLOAD,同样弹出的弹窗不需要修改,直接点击弹窗右下角的DOWNLOAD下载即可
- 这样就下载好了Idle(待机动作)、Running(跑步动作)、Jump(跳跃动作)、Death(死亡动作)的一组动作,以上动作的都可以根据个人喜好调整;如果打开mixamo.com网站比较慢或者下载有问题的话,也可以直接使用我下载好的actions里的动画模型
动画模型合并
- 打开Blender新建文件选择常规,我使用的版本是3.6.14的,不同版本可能存在差异
- 在右上角场景集合内,鼠标左键拖拽框选默认已有的Camera、Cube和Light,然后右键选择删除或者英文输入法下按下x键快速删除;后续在Blender里的所有操作,均需将输入法切换到英文输入;如果对Blender不是特别熟悉,可以优先阅读我之前整理的Blender学习整理这篇文章
- 选择左上角菜单栏里文件-->导入-->FBX(.fbx),就会弹出导入设置弹窗
- 弹窗内只需选中之前下载好的Idle.fbx文件,然后直接点击右下角导入FBX即可
- 文件导入后,在右上角场景集合内,将动画下一级的目录鼠标双击重命名成idle
- 在中间布局窗口,将右上角的视图着色方式切换到材质预览,然后把观察点切换到-X方向,将下方窗口切换到非线性动画
- 点击动画右侧的按键,作用是下推动作块(将动作块作为新的片段下推到NLA堆栈顶部),有点类似展开下拉菜单的效果,实际作用是创建NLA轨道,然后将idle动作放到一个单独的通道中
- 取消勾选通道对结果是否有影响(切换通道是否启用)
- 导入Running.fbx,导入流程和上述一致,导入后在右上角场景集合内重命名动画下一级目录为running
- 同样点击动画右侧的按键,下推动作块
- 同样取消勾选通道对结果是否有影响(切换通道是否启用)
- 鼠标点击选中idle动作,变成如下图颜色即被选中
- 选择上方添加-->添加动作片段
- 然后选中running
- 这时,在idle动作上方就添加了一个running动作通道,双击左侧如下图红框内重命名为running,左侧的名称就是我们后续使用ThreeJS控制人物模型切换动画时要使用的动作变量名
- 在右上角场景集合内,选中后面导入的模型右键后点击删除或者按下x键快速删除,将其删除
- 第一次删除可能删除不干净,场景集合内还有多余的文件,布局窗口也有遮挡物,这个遮挡物其实就是第二个模型里的人物建模,第一次只是把动画给删除掉了,所以需要再次如下图选中右侧红框内文件继续删除才能删除干净;注意删除的时候最好把导入的第一个模型收起来,防止误删
- 如上述操作后,就把idle动画和running动画合并到一个人物模型里,再重复上述操作,把Jump.fbx和Death.fbx文件依次导入进来,并且把jump动画和death动画添加到idle动画上方,每个动作一个通道;全部搞定后,可以点击如下图红框内的五角星,实心就代表被选中,依次选中每个动作,取消勾选通道影响,还可以按下键盘的空格播放各个动画
- 最后选择左上角文件-->导出-->glTF2.0(.glb/.gltf),会弹出导出设置弹窗
- 弹窗内选择本地合适的文件夹,直接点击右下角的导出glTF2.0即可
- 可以参考我仓库里的models里的actions.glb
跨栏和跑道模型获取
- 在sketchfab.com网站,首先需要先登录,同样可以使用Google邮箱登录,然后在搜索框输入hurdle,按下回车
- 然后可以在下方很多的跨栏模型中选择自己喜欢的,我这里选择的是Wii - Wii Play - Hurdle
- 点击模型左下角的Download 3D Model,会弹出下载选项弹窗
- 弹窗里选择下载GLB格式的文件,这个格式会将所有内容(包含模型的结构、材质、动画和其他元数据等)打包在一起,文件大小可能会更大,但管理方便,所有内容都在一个文件中
- 下载到本地后,重命名为hurdle.glb
- 同样的方式,搜索track,我这边使用的模型是Dusty foot path way in grass garden,忘记了当时的筛选条件了,怎么就在搜索track的时候找到了这个模型;因为需要游戏里面的跑道不间断的出现,所以需要这种能够重复拼接的跑道模型,大家也可以自行选择喜欢的模型,下载好后,本地重命名为track.glb
- 如果访问sketchfab.com网站比较慢,或者下载有问题的话,可以直接使用我仓库里的models里的hurdle.glb和track.glb
模型合并
- 为了和之前的内容不搅合,就不在之前的actions.blender文件里添加其他两个模型,这里使用Blender新建一个常规文件,同样删除默认的Camera、Cube和Light
- 选择左上角文件-->导入-->glTF2.0(.glb/.gltf),会弹出导入弹窗
- 选择之前保存的actions.glb,直接点击右下角的导入glTF2.0即可
- 导入后,同样把视图着色方式切换到材质预览,观察点切换到—X方向上
- 在右上角的场景集合内,将当前模型重命名为acitons
- 导入下载好的hurdle.glb,在右上角的场景集合内,刚导入的模型只有最里面的Object_2才是真的跨栏网格对象,外面两层结构没有作用,还可能出现外层结构的旋转缩放等属性和内部实际的网格对象的属性不一致,影响我们对实际网格对象的控制,所以最好删除掉
- 依次选中外面两层文件,按下x键删除,最后把Object_2重命名为hurdle
- hurdle模型尺寸比较大,旋转方向也不对,鼠标左键选中hurdle模型,然后在属性栏,将旋转的X调整成-1.0,将缩放的XYZ全部调整成0.1,这里不需要调整的特别精确,后续编码时还能使用ThreeJS继续调整模型的旋转和尺寸;如果布局窗口没有属性栏,可以按下n键显示/隐藏属性栏;输入值时,鼠标拖动三个输入框能够同时改变XYZ的值
- 继续选中hurdle模型,按下ctrl + a然后选择全部变换,需要把之前调整的旋转和缩放应用为自身数据
- 导入下载好的track.glb,可以切换右侧红框的眼睛图标显示/隐藏该对象来观察整体模型变化,会发现实际起作用的是Object_2和Object_3的两个网格对象,两个外层结构的显示/隐藏看似对模型的显示没有影响,其实它们是有属性是对模型有影响的,需要把它们的属性变换应用到自身
- 在右上角场景集合选中第一层结构,在属性栏会发现它有一些旋转和缩放,在布局窗口按下ctrl+a,然后选择应用全部变换;再选中第二层结构,同样按下ctrl+a应用全部变换
- 这时就会发现,外层的旋转和缩放都已经作用到Object_2和Object_3的两个网格对象上了,就可以依次选中两个外层结构,按下x键删除两个外层结构了;并且依次选中Object_2和Object_3的两个网格对象按下ctrl+a选择全部变换把外层结构的旋转和缩放应用到自身
- 然后在右上角场景集合内,鼠标先选中Object_3,按下shift键再选中Object_2,然后鼠标回到布局窗口,按下ctrl+p选择物体,这里发现Object_2变成了Object_3的子级了,理论上最后被选中的对象是父级,我想要的效果是Object_3变成了Object_2的子级,所以这里我又撤销(ctrl+z)重新先选中Object_2,按下shift键再选中Object_3,再按下ctrl+p选择物体绑定的父子关系;这里不清楚是因为我使用中文翻译的问题还是Blender有更新,有了解的大佬希望帮忙解释一下
- 将合并后的父级Object_2重命名为track
- 左键选中track模型,右键选择设置原点-->原点->几何中心
- 在选中track模型的情况下,继续按下shift+s选择选中项->游标,如果游标没有在世界原点,需要先将游标设置到世界原点
- 将观察点切换到顶视图,按下r+z绕着Z轴旋转,使跑道的长边和Y轴平行
- 再切换到-X方向,按下r+x绕着X轴旋转,使跑道的上面和Y轴平行
- 选择左上角文件-->导出-->glTF2.0(.glb/.gltf)
- 导出设置弹窗内,直接将合并后的模型导出到我们后续编码要用的文件夹内,其他无需设置,直接选择导出glTF2.0即可
编码渲染
- 使用vite搭建的项目工程,完整源码点这里code,整个渲染过程都在src/hooks/userDraw.js文件里
- 首先是ThreeJS的基础代码,放到了src/hooks/modules/base.js文件里,我这里只是往scene添加了一个背景纹理,最后就是把scene、camera、renderer暴露出来方便其他模块引用
import * as THREE from 'three';
/**
* 基础代码
*/
export default function () {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 0, 5); // 设置相机位置
const renderer = new THREE.WebGLRenderer({
antialias: true // 开启抗锯齿
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 加载背景纹理
const textureLoader = new THREE.TextureLoader();
textureLoader.load('./bg.jpeg', function (texture) {
// 将纹理设置为场景背景
scene.background = texture;
});
// 适配窗口
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; // 重置摄像机视锥体的长宽比
camera.updateProjectionMatrix(); // 更新摄像机投影矩阵
renderer.setSize(window.innerWidth, window.innerHeight); // 重置画布大小
});
return {
scene,
camera,
renderer
};
}
- 在src/hooks/modules/controls.js文件里添加控制器,并且需要禁用控制器
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/**
* 控制器
*/
export default function (camera, renderer) {
const orbitControls = new OrbitControls(camera, renderer.domElement); // 轨道控制器
orbitControls.enabled = false; // 禁用控制器
orbitControls.update(); // 更新控制器
}
- 在src/hooks/modules/light.js文件里添加环境光,上下方各添加了一个平行光
import * as THREE from 'three';
/**
* 灯光
*/
export default function (scene) {
const ambientLight = new THREE.AmbientLight(0x404040, 20); // 环境光
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight.position.set(0, 10, 5);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 5); // 平行光
directionalLight2.position.set(0, -10, -5);
scene.add(directionalLight2);
}
- src/hooks/userDraw.js文件没有进一步优化,下面主要介绍几个核心部分
- 把之前合并好的模型,使用GLTFLoader加载进来,然后初始化人物、跨栏、跑道等模型;因为模型的XYZ轴向和ThreeJS的没有对应上,所以需要给各个模型创建一个Gr0up方便单独控;获取跑道宽度时,因为获取的不是特别准确,所以又减去了2,让跑道叠加在一起了,不是特别严丝合缝;然后就是创建动画混合器,把动作保存起来了,然后默认播放待机动作;最后开始帧循环渲染
// 加载人物模型
const loader = new GLTFLoader();
loader.load('./models/group.glb', function (gltf) {
const children = [...gltf.scene.children];
// 初始化人物模型
global.characterGr0up.add(children[0]);
global.characterGr0up.rotation.set(0, Math.PI / 2, 0); // 改变人物朝向
scene.add(global.characterGr0up);
// 初始化跨栏模型
global.hurdleGr0up.add(children[1]);
global.hurdleGr0up.scale.set(0.7, 0.7, 0.7); // 缩小跨栏
global.hurdleGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跨栏朝向
global.hurdleGr0up.position.set(3, 0, 0); // 设置第一个跨栏位置
global.hurdleArr.push(global.hurdleGr0up); // 添加第一个跨栏触发碰撞检测
scene.add(global.hurdleGr0up);
// 初始化跑道模型
global.trackGr0up.add(children[2]);
global.trackGr0up.rotation.set(0, Math.PI / 2, 0); // 改变跑道朝向
scene.add(global.trackGr0up);
// 获取跑道宽度
const boundingBox = new THREE.Box3().setFromObject(global.trackGr0up); // 创建包围盒
const size = new THREE.Vector3(); // 计算包围盒的尺寸
boundingBox.getSize(size);
global.trackWidth = size.x - 2; // 跑道宽度不是特别准确,需要模糊计算
// 默认使用trackNum个跑道拼接
for (let i = 0; i < trackNum; i++) {
const newTrackModel = global.trackGr0up.clone(); // 克隆原始跑道模型
newTrackModel.position.x = i * global.trackWidth; // 按照宽度依次排列
scene.add(newTrackModel);
global.trackArr.push(newTrackModel); // 保存引用
}
// 创建动画混合器
global.animationMixer = new THREE.AnimationMixer(global.characterGr0up);
// 将每个动画剪辑存储在actions对象中
gltf.animations.forEach((clip) => {
global.actions[clip.name] = global.animationMixer.clipAction(clip);
});
// 播放默认的 idle 动作
global.currentAction = global.actions['idle'];
global.currentAction.play();
// 开始渲染循环
animate();
});
- animate函数里主要功能有开启帧循环;更新动画混合器;在跳跃动作结束后切换回跑步动作;当人物处于跑步和跳跃动作时,更新人物位置及让相机跟随人物移动,并且在移动过程中,在间隔帧数内生成新的跨栏;更新跑道的位置,如果最左侧的跑道超出屏幕后,把它移动到最右侧;当人物处于跳跃动作时,更新人物Y轴位置;如果人物和跨栏发生碰撞时,切换到死亡动作并且开启死亡状态,防止键盘按键还能继续触发;当播放完死亡动作后,提示游戏结束,并结束帧数循环;渲染场景
function animate() {
global.frame = requestAnimationFrame(animate); // 开启帧循环
global.animationMixer.update(global.clock.getDelta()); // 更新动画混合器
// 检查 jump 动作是否完成,并恢复到 running 动作
if (
global.currentAction === global.actions['jump'] &&
global.currentAction.time >= global.currentAction.getClip().duration
) {
switchAction('running', 0.3);
}
// 当处于 running 动作时,移动相机
if (
global.currentAction === global.actions['running'] ||
global.currentAction === global.actions['jump']
) {
global.characterGr0up.position.x += moveSpeed;
camera.position.x = global.characterGr0up.position.x;
// 间隔随机帧数生成跨栏
if (
global.hurdleCountFrame++ >
hurdleInterval + Math.random() * hurdleInterval
) {
generateHurdles(global.hurdleGr0up, global.hurdleArr, scene); // 生成跨栏
global.hurdleCountFrame = 0;
}
}
// 更新跑道位置
updateTrack(camera, global.trackArr, global.trackWidth);
// 当人物处于跳跃动作时,更新人物位置
updateCharacterPosition(
global.animationMixer,
global.clock,
global.currentAction,
global.actions,
global.characterGr0up
);
// 碰撞检测
if (
checkCollisions(
global.characterGr0up,
global.characterBoundingBox,
global.hurdlesBoundingBoxes,
global.hurdleArr
)
) {
switchAction('death');
global.isDeath = true;
}
// 如果 death 动作完成了,则停止帧动画
if (
global.currentAction === global.actions['death'] &&
!global.currentAction.isRunning()
) {
Modal.error({
title: 'Game Over',
width: 300
});
cancelAnimationFrame(global.frame);
}
// 渲染场景
renderer.render(scene, camera);
}
- 切换动作函数主要是在一定时间内淡出前一个动作,并且淡入新动作,如果是跳跃动作或者死亡动作的话只执行一次
function switchAction(newActionName, fadeDuration = 0.5) {
const newAction = global.actions[newActionName];
if (newAction && global.currentAction !== newAction) {
global.previousAction = global.currentAction; // 保留当前的动作
// 淡出前一个动作
if (global.previousAction) {
global.previousAction.fadeOut(fadeDuration);
}
// 如果切换到 jump 动作,设置播放一次并在结束后停止
if (newActionName === 'jump') {
newAction.loop = THREE.LoopOnce;
newAction.clampWhenFinished = true; // 停止在最后一帧
}
// 如果切换到 death 动作,设置播放一次并在结束后停止
if (newActionName === 'death') {
newAction.loop = THREE.LoopOnce;
newAction.clampWhenFinished = true; // 停止在最后一帧
}
global.currentAction = newAction; // 设置新的活动动作
// 复位并淡入新动作
global.currentAction.reset();
global.currentAction.setEffectiveTimeScale(1);
global.currentAction.setEffectiveWeight(1);
global.currentAction.fadeIn(fadeDuration).play();
}
}
- 键盘事件监听,给按键WSAD和方向键上下左右都添加了切换动作的功能,如果是死亡状态的,按键失效
window.addEventListener('keydown', (event) => {
if (global.isDeath) {
return;
}
switch (event.code) {
case 'keyD':
case 'ArrowRight':
switchAction('running');
break;
case 'keyA':
case 'ArrowLeft':
switchAction('idle');
break;
case 'keyW':
case 'ArrowUp':
switchAction('jump');
break;
}
});
- src/configs/index.js文件配置了一些常量,可以用来控制游戏状态
// 初始跑道数量
export const trackNum = 3;
// 跨栏之间的间隔帧数
export const hurdleInterval = 50; // 50~100帧之间
// 跨栏之间的间隔最小距离
export const hurdleMinDistance = 5; // 5~10距离之间
// 人物移动的速度
export const moveSpeed = 0.03;
- src/utils/index.js文件主要是一些辅助函数
import * as THREE from 'three';
import { hurdleMinDistance } from '../configs/index';
/**
* 生成新的跨栏
*
* @param {Object} oldModel - 要克隆的原始跨栏模型。
* @param {Array} hurdleArr - 现有跨栏模型的数组。
* @param {Object} scene - 要添加新跨栏模型的场景。
* @return {undefined}
*/
export function generateHurdles(oldModel, hurdleArr, scene) {
const newModel = oldModel.clone(); // 克隆原始跨栏模型
const nextPosition =
hurdleArr[hurdleArr.length - 1].position.x +
hurdleMinDistance +
Math.random() * hurdleMinDistance;
newModel.position.set(nextPosition, 0, 0);
hurdleArr.push(newModel);
scene.add(newModel);
}
/**
* 更新跑道位置
*
* @param {Object} camera - 具有位置属性的摄像机对象。
* @param {Array} trackArr - 具有位置属性的轨道段对象数组。
* @param {Number} trackWidth - 每个轨道段的宽度。
* @return {undefined}
*/
export function updateTrack(camera, trackArr, trackWidth) {
const cameraPositionX = camera.position.x; // 相机的 x 坐标
// 遍历所有跑道段
for (let i = 0; i < trackArr.length; i++) {
const trackSegment = trackArr[i];
// 提前检测跑道段是否即将超出视野(增加一个提前量,比如半个跑道段的宽度)
const threshold = cameraPositionX - trackWidth * 1.5;
if (trackSegment.position.x < threshold) {
// 找到当前最右边的跑道段
let maxX = -Infinity;
for (let j = 0; j < trackArr.length; j++) {
if (trackArr[j].position.x > maxX) {
maxX = trackArr[j].position.x;
}
}
// 将当前跑道段移动到最右边
trackSegment.position.x = maxX + trackWidth;
}
}
}
/**
* 人物跳跃时,更新人物Y轴位置
*
* @param {Object} animationMixer - 动画混合器对象。
* @param {Object} clock - 用于获取增量时间的时钟对象。
* @param {Object} currentAction - 当前正在执行的动作。
* @param {Object} action - 可用动作的集合。
* @param {Object} characterGr0up - 角色组对象。
* @return {undefined}
*/
export function updateCharacterPosition(
animationMixer,
clock,
currentAction,
actions,
characterGr0up
) {
// 更新动画混合器
animationMixer.update(clock.getDelta());
// 检查动画状态并调整位置
if (currentAction === actions['jump']) {
// 根据跳跃动画的时间调整人物位置
const jumpHeight = 0.8; // 你可以调整这个值
characterGr0up.position.y =
Math.sin(currentAction.time * Math.PI) * jumpHeight;
} else {
characterGr0up.position.y = 0; // 恢复到地面位置
}
}
/**
* 检测人物是否与跨栏发生了碰撞
*
* @param {Object} characterGr0up - 角色组对象。
* @param {Object} characterBoundingBox - 角色的边界框对象。
* @param {Array} hurdlesBoundingBoxes - 跨栏的边界框数组。
* @param {Array} hurdleArr - 跨栏对象数组。
* @return {Boolean} 是否发生了碰撞。
*/
export function checkCollisions(
characterGr0up,
characterBoundingBox,
hurdlesBoundingBoxes,
hurdleArr
) {
// 更新人物的边界框
if (characterGr0up) {
characterBoundingBox.setFromObject(characterGr0up);
}
// 更新跨栏的边界框
hurdlesBoundingBoxes = hurdleArr.map((hurdle) => {
const box = new THREE.Box3();
box.setFromObject(hurdle);
return box;
});
for (let i = 0; i < hurdlesBoundingBoxes.length; i++) {
if (characterBoundingBox.intersectsBox(hurdlesBoundingBoxes[i])) {
return true; // 检测到碰撞
}
}
return false; // 没有检测到碰撞
}
不足
- 跑道的纹理和材质没有渲染出来,不知道是否是导出的模型有问题,有懂的大佬可以帮忙看看
- 目前合并后的模型导出后体积比较大,还需要解决模型压缩的问题
来源:juejin.cn/post/7405153695506022451
工作中用Redis最多的10种场景
前言
Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。
今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。
1. 统计访问次数
对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。
访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。
该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。
在Redis中有incr命令,可以实现给value值加1操作:
incr OFFICIAL_INDEX_VISIT_COUNT
当然如果你想一次加的值大于1,可以用incrby命令,例如:
incrby OFFICIAL_INDEX_VISIT_COUNT 5
这样可以一次性加5。
2. 获取分类树
在很多网站都有分类树的功能,如果没有生成静态的html页面,想通过调用接口的方式获取分类树的数据。
我们一般为了性能考虑,会将分类树的json数据缓存到Redis当中,为了后面在网站当中能够快速获取数据。
不然在接口中需要使用递归查询数据库,然后拼接成分类树的数据结构。
这个过程非常麻烦,而且需要多次查询数据库,性能很差。
因此,可以考虑用一个定时任务,异步将分类树的数据,直接缓存到Redis当中,定义一个key,比如:MALL_CATEGORY_TREE。
然后接口中直接使用MALL_CATEGORY_TREE这个key从缓存中获取数据即可。
可以直接用key/value字符串保存数据。
不过需要注意的是,如果分类树的数据非常多可能会出现大key的问题,优化方案可以参考我的另外一篇文章《分类树,我从2s优化到0.1s》。
3. 做分布式锁
分布式锁可能是使用Redis最常见的场景之一,相对于其他的分布式锁,比如:数据库分布式锁或者Zookeeper分布式锁,基于Redis的分布式锁,有更好的性能,被广泛使用于实际工作中。
我们使用下面这段代码可以加锁:
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}
但上面这段代码在有些场景下,会有一些问题,释放锁可能会释放了别人的锁。
说实话Redis分布式锁虽说很常用,但坑也挺多的,如果用不好的话,很容易踩坑。
如果大家对Redis分布式锁的一些坑比较感兴趣,可以看看我的另一篇文章《聊聊redis分布式锁的8大坑》,文章中有非常详细的介绍。
4. 做排行榜
很多网站有排行榜的功能,比如:商城中有商品销量的排行榜,游戏网站有玩家获得积分的排行榜。
通常情况下,我们可以使用Sorted Set
保存排行榜的数据。
使用ZADD
可以添加排行榜的数据,使用ZRANGE
可以获取排行榜的数据。
例如:
ZADD rank:score 100 "周星驰"
ZADD rank:score 90 "周杰伦"
ZADD rank:score 80 "周润发"
ZRANGE rank:score 0 -1 WITHSCORES
返回数据:
1) "周星驰"
2) "100"
3) "周杰伦"
4) "90"
5) "周润发"
6) "80"
5. 记录用户登录状态
通常下,用户登录成功之后,用户登录之后的状态信息,会保存到Redis中。
这样后面该用户访问其他接口的时候,会直接从Redis中查询用户登录状态,如果可以查到数据,说明用户已登录,则允许做后续的操作。
如果从Redis中没有查到用户登录状态,说明该用户没有登录,或者登录状态失效了,则直接跳转到用户登录页面。
使用Redis保存用户登录状态,有个好处是它可以设置一个过期时间,比如:该时间可以设置成30分钟。
jedis.set(userId, userInfo, 1800);
在Redis内部有专门的job,会将过期的数据删除,也有获取数据时实时删除的逻辑。
6. 限流
使用Redis还有一个非常常用的的业务场景是做限流
。
当然还有其他的限流方式,比如:使用nginx,但使用Redis控制可以更精细。
比如:限制同一个ip,1分钟之内只能访问10次接口,10分钟之内只能访问50次接口,1天之内只能访问100次接口。
如果超过次数,则接口直接返回:请求太频繁了,请稍后重试。
跟上面保存用户登录状态类似,需要在Redis中保存用户的请求记录。
比如:key是用户ip,value是访问的次数从1开始,后面每访问一次则加1。
如果value超过一定的次数,则直接拦截这种异常的ip。
当然也需要设置一个过期时间,异常ip如果超过这个过期时间,比如:1天,则恢复正常了,该ip可以再发起请求了。
或者限制同一个用户id。
7. 位统计
比如现在有个需求:有个网站需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。
这个需求使用传统的数据库,实现起来比较麻烦,但使用Redis的bitmap
让我们可以实时的进行类似的统计。
bitmap 是二进制的byte数组,也可以简单理解成是一个普通字符串。它将二进制数据存储在byte数组中以达到存储数据的目的。
保存数据命令使用setbit,语法:
setbit key offset value
具体示例:
setbit user:view:2024-01-17 123456 1
往bitmap数组中设置了用户id=123456的登录状态为1,标记2024-01-17已登录。
然后通过命令getbit获取数据,语法:
getbit key offset
具体示例:
getbit user:view:2024-01-17 123456
如果获取的值是1,说明这一天登录了。
如果我们想统计一周内连续登录的用户,只需要遍历用户id,根据日期中数组中去查询状态即可。
最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
进群方式
添加,苏三的私人微信:su_san_java,备注:内推+所在城市,即可加入。
8. 缓存加速
我们在工作中使用Redis作为缓存加速,这种用法也是非常常见的。
如果查询订单数据,先从Redis缓存中查询,如果缓存中存在,则直接将数据返回给用户。
如果缓存中不存在,则再从数据库中查询数据,如果数据存在,则将数据保存到缓存中,然后再返回给用户。
如果缓存和数据库都不存在,则直接给用户返回数据不存在。
流程图如下:但使用缓存加速的业务场景,需要注意一下,可能会出现:缓存击穿、穿透和雪崩等问题,感兴趣的小伙伴,可以看看我的另一篇文章《烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了?》,里面有非常详细的介绍。
9. 做消息队列
我们说起队列经常想到是:kafka、rabbitMQ、RocketMQ等这些分布式消息队列。
其实Redis也有消息队列的功能,我们之前有个支付系统,就是用的Redis队列功能。
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。
顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。对应channel发送消息后,所有订阅者都能收到相关消息。
在java代码中可以实现MessageListener接口,来消费队列中的消息。
@Slf4j
@Component
public class RedisMessageListenerListener implements MessageListener {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(pattern);
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
Object deserialize = valueSerializer.deserialize(message.getBody());
if (deserialize == null) return;
String md5DigestAsHex = DigestUtils.md5DigestAsHex(deserialize.toString().getBytes(StandardCharsets.UTF_8));
Boolean result = redisTemplate.opsForValue().setIfAbsent(md5DigestAsHex, "1", 20, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(result)) {
log.info("接收的结果:{}", deserialize.toString());
} else {
log.info("其他服务处理中");
}
}
}
10. 生成全局ID
在有些需要生成全局ID的业务场景,其实也可以使用Redis。
可以使用incrby命令,利用原子性操作,可以执行下面这个命令:
incrby userid 10000
在分库分表的场景,对于有些批量操作,我们可以从Redis中,一次性拿一批id出来,然后给业务系统使用。
来源:juejin.cn/post/7325132133168971813
访问者模式:多品类商品打折场景
0.基础
0.0解决的痛点
它可以在不修改原有类的情况下,扩展新的操作,而策略模式则需要改变上下文类来引入新的策略。
- 扩展性更强
- 访问者模式使得你可以在不修改现有类的情况下添加新的操作。假设你有多个对象构成的复杂结构,并且要在这些对象上执行不同的操作。
- 使用访问者模式,你可以为每个对象定义一个访问者,而每次需要添加新的操作时,只需要增加一个新的访问者类。这样,原有类不会被修改,符合开闭原则(对扩展开放,对修改关闭)。
- 相反,策略模式更多是通过替换算法来改变行为。如果你的业务逻辑复杂,需要在同一个对象中实现多个策略,频繁地改变策略可能会导致对象内部逻辑变得非常复杂,增加维护成本。
- 职责单一,逻辑分离
- 访问者模式将行为与对象结构分离,访问者本身只关心如何对不同对象执行操作,而不需要关心对象的具体实现。这种方式将操作逻辑与数据结构解耦,符合单一职责原则。
- 策略模式中,每种策略会嵌入到目标对象中,这会使得目标对象承担过多的责任,尤其是在需要处理大量策略的情况下,会导致对象变得非常臃肿。
- 操作集中统一管理
- 使用访问者模式时,所有的操作都集中在访问者类中进行管理。
- 假设有多个元素需要执行不同的操作,访问者模式将这些操作集中到访问者中,避免了分散在各个策略中的问题,便于管理和维护。
- 策略模式则往往需要将每个策略分散在不同的策略类中,随着策略增多,管理和维护会变得越来越困难,尤其是当策略之间有依赖或交互时,复杂性会迅速增加。
- 适合复杂结构对象的处理
- 访问者模式特别适合在对象结构复杂且需要遍历的场景中使用。
- 例如,树形结构或对象图的遍历,这时每个节点的处理逻辑可以独立出来,并通过访问者来实现。
- 访问者可以对这些节点类型的元素进行访问和操作,无需修改元素类本身。
- 策略模式一般用于动态地改变同一对象的行为,不适合处理复杂的对象结构,特别是当需要在多个元素中进行遍历和操作时,策略模式会显得不够灵活。
- 总结:
- 访问者模式更适合在你需要对复杂结构的对象执行多个操作,并且希望操作与对象本身分离的场景。
- 它更方便扩展,避免了复杂的继承结构或不断修改已有类。
- 而策略模式则适合于在单一对象上动态替换行为,但对于复杂对象结构的处理往往会导致逻辑分散,扩展性差。
0.1代码结构图
0.2业务流程图
0.3请求Json
localhost:8080/VisitorPattern/calculateDiscount
POST类型
["electronics", "clothing", "food"]
1.代码结构
1.1Pojo
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;
public interface Product {
void accept(ProductVisitor visitor);
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
public interface ProductVisitor {
void visit(Electronics electronics);
void visit(Clothing clothing);
void visit(Food food);
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Clothing implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Electronics implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Food implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
public class DiscountVisitor implements ProductVisitor {
private double totalDiscount = 0;
@Override
public void visit(Electronics electronics) {
//电子产品打八折
totalDiscount += electronics.getPrice()*0.2;
log.info("[访问者模式]:电子产品打8折后价格为"+electronics.getPrice()*0.2+"原价为"+electronics.getPrice());
}
@Override
public void visit(Clothing clothing) {
//衣物商品打7折
totalDiscount+=clothing.getPrice()*0.3;
log.info("[访问者模式]衣物商品打7折后价格为"+clothing.getPrice()*0.2+"原价为"+clothing.getPrice());
}
@Override
public void visit(Food food) {
//食品商品打9折
totalDiscount += food.getPrice()*0.1;
log.info("[访问者模式]食品商品打9折后价格为"+food.getPrice()*0.2+"原价为"+food.getPrice());
}
}
1.2Service
package com.xiaoyongcai.io.designmode.Service.VisitorPattern;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl.DiscountVisitor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
public double calculateTotalDiscount(List<Product> products) {
// 创建一个DiscountVisitor实例
DiscountVisitor discountVisitor = new DiscountVisitor();
// 遍历每个商品,执行折扣计算
for (Product product : products) {
product.accept(discountVisitor);
}
// 返回总折扣
return discountVisitor.getTotalDiscount();
}
}
1.3Controller
package com.xiaoyongcai.io.designmode.Controller.VisitorPattern;
import com.xiaoyongcai.io.designmode.Service.VisitorPattern.ProductService;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("VisitorPattern")
public class ProductController {
@Autowired
private ProductService productService;
@PostMapping("/calculateDiscount")
public String calculateDiscount(@RequestBody List<String> productTypes) {
List<Product> products = new ArrayList<>();
// 根据传入的商品类型,创建不同的商品实例
for (String type : productTypes) {
switch (type) {
case "electronics":
products.add(new Electronics(100)); // 假设价格是100
break;
case "clothing":
products.add(new Clothing(150)); // 假设价格是150
break;
case "food":
products.add(new Food(50)); // 假设价格是50
break;
}
}
productService.calculateTotalDiscount(products);
// 调用Service层计算折扣
return "请在控制台检查访问者模式是否工作";
}
}
来源:juejin.cn/post/7440842636228919348
那些大厂架构师是怎样封装网络请求的?
好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便
一、前言
网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会到。
网络框架的发展:
1. 从最早的HttpClient
到 HttpURLConnection
,那时候需要自己用线程池封装异步,Handler切换到UI线程,要想从网络层就返回接收实体对象,也需要自己去实现封装
2. 后来,谷歌的 Volley
, 三方的 Afinal
再到 XUtils
都是基于上面1中的网络层再次封装实现
3. 再到后来,OkHttp
问世,Retrofit
空降,从那以后基本上网络请求应用层框架就是 OkHttp
和 Retrofit
两套组合拳,基本打遍天下无敌手,最多的变化也就是在这两套组合拳里面秀出各种变化,但是思想实质上还是这两招。
我们试想:从当初的大概2010年,2011年,2012年开始,就启动一个App项目,就网络这一层的封装而言,随着时代的潮流,技术的演进,我们势必会经历上面三个阶段,这一层的封装就得重构三次。
现在是2024年,往后面发展,随着http3.0的逐渐成熟,一定会出现更好的网络请求框架
我们怎么封装一套更容易扩展的框架,而不必每次重构这一层时,改动得那么困难。
本文下面就示例这一思路如何封装,涉及到的知识,jetpack
中的手术刀: Hilt
成员来帮助我们实现。
二 、示例项目
- 上图截图圈出的就是本文重点介绍的内容:
怎么快速封装一套可以切换网络框架的项目
及相关Jetpack中的 Hilt
用法 - 其他的1,2,3,4是之前我写的:花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路,大家可以参考,也可以在它的基础上,再结合本文再次封装,可以作为
花式玩法五
三、网络层代码设计
1. 设计请求接口,包含请求地址 Url
,请求头,请求参数,返回解析成的对象Class
:
interface INetApi {
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
*/
suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, map: MutableMap<String, Any>? = null): R
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
* @param body:请求body
*/
suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, body: String? = null): R
}
2. 先用早期 HttpURLConnection
对网络请求进行实现:
class HttpUrlConnectionImpl constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
//这里HttpUrlConnectionRequest内部是HttpURLConnection的Get请求真正的实现
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
////这里HttpUrlConnectionRequest内部是HttpURLConnection的Post请求真正的实现
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
3. 整个项目 build.gradle
下配置 Hilt插件
buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}
}
4. 工程app的 build.gradle
下引入:
先配置:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'dagger.hilt.android.plugin'//Hilt使用
id 'kotlin-kapt'//
}
里面的 android
下面添加:
kapt {
generateStubs = true
}
在 dependencies
里面引入 Hilt
使用
//hilt
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-android-compiler:2.42"
kapt 'androidx.hilt:hilt-compiler:1.0.0'
5. 使用 Hilt
5.1 在Application上添加注解 @HiltAndroidApp
:
@HiltAndroidApp
class MyApp : Application() {
}
5.2 在使用的Activity上面添加注解 @AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseViewModelActivity<MainViewModel>(R.layout.activity_main), View.OnClickListener {
override fun onClick(v: View?) {
when (v?.id) {
R.id.btn1 -> {
viewModel.getHomeList()
}
else -> {}
}
}
}
5.3 在使用的ViewModel上面添加注解 @HiltViewModel
和 @Inject
:
@HiltViewModel
class MainViewModel @Inject constructor(private val repository: NetRepository) : BaseViewModel() {
fun getHomeList() {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList().onEach {
val title = it.datas!![0].title
android.util.Log.e("MainViewModel", "one 111 ${title}")
errorMsgLiveData.postValue(title)
}
}
}
}
5.4 在 HttpUrlConnectionImpl
构造方法上添加注解 @Inject
如下:
class HttpUrlConnectionImpl @Inject constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
5.5 新建一个 annotation
: BindHttpUrlConnection
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindHttpUrlConnection()
5.6 再建一个绑定网络请求的 abstract
修饰的类 AbstractHttp
如下:让 @BindHttpUrlConnection
和 HttpUrlConnectionImpl
在如下方法中通过注解绑定
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.7 在viewModel持有的仓库类 NetRepository
的构造方法中添加 注解 @Inject
,并且申明 INetApi
,并且绑定注解 @BindHttpUrlConnection
如下: 然后即就可以开始调用 INetApi
的方法
class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
到此:Hilt使用就配置完成了,那边调用 网络请求就直接执行到 网络实现 类 HttpUrlConnectionImpl
里面去了。
运行结果看到代码执行打印:
5.8 我们现在切换到 Okhttp
来实现网络请求:
新建 OkhttpImpl
实现 INetApi
并在其构造方法上添加 @Inject
如下:
class OkhttpImpl @Inject constructor() : INetApi {
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
try {
val request = Request.Builder().url(buildParamUrl(url, map))
header?.forEach {
request.addHeader(it.key, it.value)
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
val json = response.body?.string()
android.util.Log.e("OkhttpImpl","okhttp 请求:${json}")
return gson.fromJson<R>(json, clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
try {
val request = Request.Builder().url(url)
header?.forEach {
request.addHeader(it.key, it.value)
}
body?.let {
request.post(RequestBodyCreate.toBody(it))
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
return gson.fromJson<R>(response.body.toString(), clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
}
5.9 再建一个注解 annotation
类型的 BindOkhttp
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindOkhttp()
5.10 在 AbstractHttp
类中添加 @BindOkhttp
绑定到 OkhttpImpl
,如下:
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindOkhttp
@Singleton
@Binds
abstract fun bindOkhttp(h: OkhttpImpl): INetApi
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.11 现在只需要在 NetRepository
中持有的 INetApi
修改其绑定的 注解 @BindHttpUrlConnection
改成 @BindOkhttp
便可以将项目网络请求全部改成由 Okhttp
来实现了,如下:
//class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
class NetRepository @Inject constructor(@BindOkhttp val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
运行执行结果截图可见:
到此:网络框架切换就这样简单的完成了。
四、总结
- 本文重点介绍了,怎么对网络框架扩展型封装:即怎么可以封装成快速从一套网络请求框架,切换到另一套网络请求上去
- 借助于
Jetpack中成员 Hilt
对其整个持有链路进行切割,简单切换绑定网络实现框架1,框架2,框架xxx等。
项目地址
感谢阅读:
欢迎 点赞、收藏、关注
这里你会学到不一样的东西
来源:juejin.cn/post/7435904232597372940
这段时间 weapp-vite 的功能更新与优化
这段时间 weapp-vite
的功能更新与优化
自从上次宣布 weapp-vite
的发布,已经过去三个月;weapp-vite
也逐渐迭代至 1.7.6
版本。
在此期间,我对其进行了多项功能的增强和优化,接下来我将为大家详细介绍近期的阶段性成果。
下面列出的功能皆为增强特性,开发者可自由选择启用或关闭,不影响原生小程序的兼容性。
核心功能更新
1. 自动构建 npm
在项目启动时,weapp-vite
会自动构建 npm
依赖,无需再手动点击微信开发者工具中的 构建 npm
,提升了一定程度的开发体验。
详细信息请参考:自动构建 npm 文档。
2. 语法增强
2.1 JSON
文件增强
1. 支持注释
weapp-vite
支持在项目中的 JSON
文件中添加注释。例如:
{
/* 这是一个组件 */
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {
// 导航栏组件
"navigation-bar": "@/navigation-bar/navigation-bar"
}
}
这些注释会在最终产物内被去除。
注意:
project.config.json
和project.private.config.json
不支持注释,因为这些文件直接由微信开发者工具读取。
2. 智能提示
我生成了许多小程序的 $schema
文件,部署在 vite.icebreaker.top
上。
通过指定 JSON
的 $schema
字段,实现了配置文件的智能提示功能,优化了一点点开发体验。
详见:JSON 配置文件的智能提示。
3. 别名支持
可以在 vite.config.ts
中配置 jsonAlias.entries
字段, 在 usingComponents
中使用别名定义路径,这些在构建时会自动转化为相对路径。
例如:
import type { UserConfig } from 'weapp-vite/config'
import path from 'node:path'
export default <UserConfig>{
weapp: {
jsonAlias: {
entries: [
{
find: '@',
replacement: path.resolve(__dirname, 'components'),
},
],
},
},
}
那么就可以在 json
中这样编写:
{
"usingComponents": {
"navigation-bar": "@/navigation-bar/navigation-bar",
"ice-avatar": "@/avatar/avatar"
}
}
构建结果:
{
"usingComponents": {
"navigation-bar": "../../components/navigation-bar/navigation-bar",
"ice-avatar": "../../components/avatar/avatar"
}
}
4. 编程支持
weapp-vite
支持使用 JS/TS
文件来编写 JSON
,你需要将 component.json
更改为 component.json.ts
:
智能提示定义
API
都在weapp-vite/json
中导出
比如普通写法:
import { defineComponentJson } from 'weapp-vite/json'
export default defineComponentJson({
component: true,
styleIsolation: 'apply-shared',
usingComponents: {},
})
还支持引入异步数据、编译时变量或其他文件:
import type { Page } from 'weapp-vite/json'
import fs from 'node:fs/promises'
import path from 'node:path'
import shared0 from '@/assets/share'
import shared1 from './shared.json'
console.log('import.meta.env: ', import.meta.env)
console.log('import.meta.dirname: ', import.meta.dirname)
console.log('MP_PLATFORM: ', import.meta.env.MP_PLATFORM)
console.log(import.meta.env.DEV, import.meta.env.MODE, import.meta.env.PROD)
const key = await fs.readFile(
path.resolve(import.meta.dirname, 'x.txt'),
'utf8'
)
export default <Page>{
usingComponents: {
't-button': 'tdesign-miniprogram/button/button',
't-divider': 'tdesign-miniprogram/divider/divider',
'ice-avatar': '@/avatar/avatar',
},
...shared0,
...shared1,
key,
}
2.2 WXML
文件增强
事件绑定语法糖
weapp-vite
借鉴了 Vue
的事件绑定风格,为 WXML
增加了事件绑定语法糖:
这里我们以最常用的 tap
事件为例:
<!-- 原始代码 -->
<view @tap="onTap"></view>
<!-- 编译后 -->
<view bind:tap="onTap"></view>
支持的事件绑定增强规则如下:
源代码 | 编译结果 |
---|---|
@tap | bind:tap |
@tap.catch | catch:tap |
@tap.mut | mut-bind:tap |
@tap.capture | capture-bind:tap |
@tap.capture.catch / @tap.catch.capture | capture-catch:tap |
详见:事件绑定增强文档。
这部分还能做的更多,欢迎与我进行讨论!
2.3 WXS
增强
编程支持(实验性)
weapp-vite
为 WXS
提供了 JS/TS
编程支持,支持通过更改 wxs
后缀为 wxs.js
或 wxs.ts
文件定义逻辑:
比如 index.wxs.ts
:
export const foo = '\'hello world\' from hello.wxs.ts'
export const bar = function (d: string) {
return d
}
另外内联 WXS
也支持使用 lang="js"
或 lang="ts"
直接启用编译功能:
<view>{{test.foo}}</view>
<view @tap="{{test.tigger}}">{{test.abc}}</view>
<wxs module="test" lang="ts">
const { bar, foo } = require('./index.wxs.js')
const bbc = require('./bbc.wxs')
export const abc = 'abc'
export function tigger(value:string){
console.log(abc)
}
export {
foo,
bar,
bbc
}
</wxs>
详情请参考:Wxs 增强。
3. 生成脚手架
weapp-vite
内置了生成脚手架工具,可快速生成一系列文件(如 js
、wxml
、wxss
和 json
),用于提升开发效率。
最基础的用法只需要 weapp-vite g [outDir]
详情请参考:生成脚手架文档。
4. 分包支持
针对普通分包和独立分包的加载需求进行了优化,用户几乎无需额外配置即可实现分包加载。
尤其是独立分包的场景,创建了独立的编译上下文。
详情请参考:分包加载文档。
不忘初心,持续改进
weapp-vite
的初衷是实现对原生小程序的增强,现有原生小程序几乎可以零成本地迁移过来,并享受更高效的开发体验。
在此,希望各位开发者试用,欢迎反馈与参与。
如果您对文中的任何功能或增强有疑问、建议,欢迎到 Github Discussions 提出讨论!
来源:juejin.cn/post/7437876830487363599
JavaScript内存管理机制解析
前言
内存,作为计算机系统中存储数据和指令的关键资源,其管理效率直接影响着程序的性能和稳定性。在JavaScript
的世界里,理解内存机制并非遥不可及,每一位开发者必须面对并掌握的实用技能。无论是初涉开发的新手,还是经验丰富的老手,深入理解JavaScript
的内存机制都是通往更高层次编程能力的必经之路。
语言类型
静态语言
静态语言是指在编译时变量的数据类型就已经确定的语言,比如java
定义一个整数类型需要先用int
去定义一个变量。这类语言在编写程序时,要求开发者明确地声明变量的类型,并且在程序的整个生命周期内,该变量的类型都不能改变。换句话说,静态语言的类型检查是在编译阶段完成的,而不是在运行时,常见的静态语言包括Java、C++、C#、Go
等。
动态语言
动态语言(Dynamic Language),也称为动态编程语言或动态类型语言,与静态语言相反,是指在程序运行时可以改变其结构的语言。这种改变可能包括引进新的函数、删除已有的函数,或者在运行时确定变量的类型等。动态语言的特点使得它们通常具有更高的灵活性和表达能力。常见的动态语言有我们学的JavaScript,还有Python,PHP
等。
弱类型语言
弱类型语言是指变量的类型检查和转换方式相对宽松的一种编程语言。在弱类型语言中,变量可以在不明确声明类型的情况下直接使用,并且在运行时可以自动改变类型,或者可以在不同类型之间自由进行操作和转换,常见的弱类型语言包括JavaScript、Python
等。
强类型语言
强类型语言(Strongly Typed Language)是一种在编译时期就进行类型检查的编程语言。这类语言要求变量在使用前必须明确声明其类型,并且在使用过程中,变量的类型必须保持一致,不能随意更改,常见的强类型语言包括Java、C++、C#、Go
等。
数据类型
在每种语言里面都会有一个方法去查看数据的类型,js
也不例外,我们可以用typeof
去查看一个数据的类型,那我们来看看js
中所有的数据类型吧
let a = 1
// console.log(typeof a); //Number
a = 'hello'
// console.log(typeof a); //String
a = true
// console.log(typeof a); //boolean
a = null
// console.log(typeof a); //object
a = undefined
// console.log(typeof a); //undefined
a = Symbol(1)
// console.log(typeof a); //symbol
a = 123n
// console.log(typeof a); //bigint
a = []
// console.log(typeof a); // object
a = {}
// console.log(typeof a); //object
a = function () {}
// console.log(typeof a); // function
我们可以看到所有判断类型的结果,大部分还正常,可是数组和null
怎么也被判断成了object
类型呢?
那我们要来了解一下typeof
的判断原理,怎么给a
判断出来它的数据类型的呢,其实是通过转换为计算机能看懂的二进制,然后通过二进制的数据进行的分析,所有的引用类型转换成二进制前三位一定是零,然后数组是引用类型,而typeof
判断时如果前三位是零,那么就无脑认为它是object
类型,但是函数是一个特例,在js
中函数是一个对象,它做了一些特殊操作,所以能够判断出来,但是null
是原始数据类型,为什么也能被判断为object
类型呢,因为null
在被读取成二进制时,它会被读取为全是零。而这个不是编程语言能够决定的,在计算机创建出来时就是这样设定的,因此这是一个bug
,在设计这门语言的的bug
,这个bug
如果要修复并不困难,但是一旦修复,所有用js语言开发的项目都需要修复,影响太大,因此这个bug
就被默认为js
语言里面的规则。
内存空间
内存空间的分布
在v8引擎执行你写的代码时,会占用一部分的运行空间,而执行时占用的内存空间在v8的视角里会被分布成这个样子的
代码空间是专门存储你所写的代码,栈空间就是我们之前讲过的调用栈juejin.cn/post/743706…
用来存储函数被调用时,它的执行上下文,维护函数的调用关系,调用栈被分布的空间是比较小的。
堆空间(Heap Space)是内存管理的一个重要部分,它用于存储动态分配的对象和数据结构。
栈和堆之间的关系
让我们来看看栈和堆之间的关系
function foo() {
var a = 1
var b = a
var c = {name: '熊总'}
var d = c
}
foo()
此时foo函数
已经完成编译,且已经执行到了b=a
这一行,然后将一个对象赋值给c
的时候,并不会直接把这个对象存储在函数的执行上下文里面,而是会在旁边在创建一个堆空间,将对象存储在堆空间里面,而这个c
存储的就是对象在堆空间的地址值
然后在执行将c
的值赋给d其实就是将对象的地址值赋值给了d
,因此c
和d
的地址值指向的是同一个对象,并没有创建出一个新的对象,如果这个对象发生改变,那么c
和d
所代表的对象都会发生改变。
那为什么原始数据类型可以直接存储在栈当中,而引用数据类型却要存储在堆空间里面,因为原始类型数据所占的空间小,而引用数据类型所占的空间较大,比如一个对象,它可以有无数个属性,而原始类型,它就只有一个固定的值,所占内存不大,而栈被分布的空间比较小,堆被分布的空间比较大,因此原始数据类型可以直接存储在栈当中,而引用数据类型要存储在堆当中。
栈设计为什么这么小
首先我们要明白栈是用来维护函数的调用关系,而如果将栈设计的很大,那么程序员就可以写很长作用域链,并且不考虑代码的执行效率,写出不断嵌套的屎山代码。举个例子,栈就好比在你身上的空间,比如你的衣服裤子口袋,而堆就相当于一个分层的柜子,你把衣服上的口袋设计的很大,不要柜子,把你的东西全部装在口袋里面,首先看起来就十分丑陋,其次,你如果想将你想要的东西拿出来就要在口袋里翻来覆去的找,那样的效率是很低的
成果检验
function fn(person) {
person.age = 19
person = {
name: '庆玲',
age: 19
}
return person
}
const p1 = {
name: '凤如',
age: 18
}
const p2 = fn(p1)
console.log(p1);
console.log(p2);
请分析上面的代码中的p1
和p2
的输出结果
我们创建全局上下文进行编译执行,然后对函数fn进行编译,编译过程中形参和实参要进行统一,接下来,我们要开始执行函数fn
了,首先它将p1
所指向的对象age
修改为了19
,然后再函数中它将p1
的地址值修改指向为了新对象,并将新对象返回,然后在全局接着执行,将返回的地址值赋给了p2
,所以p2的值就是函数中新对象的地址值,接下来输出p1
,此时函数已经执行完毕,在调用栈中被销毁了,那我们就在全局中查找,在全局中p1
的指向就是#001
,但是函数销毁前他将地址值为#001
的对象age
属性修改为19
,所以p1
打印出来的只有age
改为了19
,而p2
就是返回的新对象的值,然我们看看结果是不是我们分析的那样
没错,p1
的name
为'凤如',age
为19
,p2
的name
为'庆玲',age
为19
最后来一道添加闭包的内存管理机制代码分析,如果不熟悉闭包的概念,可以先看看这篇文章
](juejin.cn/post/743814…)
function foo() {
var myname = '彭于晏'
let test1 = 1
const test2 = 2
var innerBar = {
setName: function (name) {
myname = name
},
getName: function () {
console.log(test1);
return myname
}
}
return innerBar
}
var bar = foo()
bar.setName('金城武')
console.log(bar.getName());
总结
本文探讨了JavaScript
的内存机制,包括语言类型(静态与动态、强类型与弱类型)、数据类型及typeof
的判断原理,并解析了内存空间的分布,特别是栈空间和堆空间的作用及它们之间的关系。通过示例代码,阐述了原始数据类型和引用数据类型在内存中的存储差异,以及栈为何设计得相对较小的原因。最后,通过实际代码演示和结果分析,检验了对JavaScript
内存机制的理解。本文是掌握JavaScript
编程能力的关键一步,适合各层次开发者阅读。
来源:juejin.cn/post/7440717815709057050
surya,一个优秀的OCR开源项目,更专注于表格识别
写这篇文件,因为一条评论。
我写过识别计算器屏幕的文章,讲了它可以独立部署在App、小程序、树莓派等嵌入式设备上。有个老哥说,这都是应用多年的老技术了。
他说得没错,可能多少年前就有了。不过,实际上,一项技术不管应用了多少年,每年依然还有人花钱去做新的。
不知道八零、九零后是否还记得,零几年时的非智能手机,就已经有了手写识别这个功能。甚至它还给你配一个手写笔。
即便这项技术已经30年了。你现在去软件企业问问,如果他家的产品需要一个手写识别的功能,他是不是依然还得花钱买第三方的服务。
为啥?
这个问题非常好,值得思考。
首先,那时候的技术和现在不一样。在非AI时代,手写识别用的是模板匹配技术,是基于规则的。你写的字,它要拿去库里同模板做比对。就像是机器人客服,靠关键字回复一样。你问“房子”它知道,问“屋子”它说听不懂。而现在的手写识别是基于深度学习的,你问它house,它一样知道是住的地方。
其次,就算技术没变化,它的落地实践也是有壁垒的。这体现在两点。第一,给你一个开源项目,你不一定能用起来。第二,每个人的细分需求不一样,就算你能跑起来,也很难改动,个性化定制。
今天,我就讲解一个开源的OCR项目。以此论证我的观点。
我看到这个项目公布好几个月了,各类新闻也是来了一波又一波:《比xx更好的用OCR》《表格识别神器》《今年最火的十个开源项目》……
大家都在传播、转发,哎呀,这个东西好,好用。包括我做卷帘门的朋友都分享给我,说这个好用。但是,没有谁发一篇文章,说他真的用了,是如何用的,效果怎么样,它的原理是什么,它是如何训练的,它有什么优点,它有什么缺点,缺点是否能优化,该如何优化。今天,我就来填补一下。不然又会有人说,哎呀,这东西早就解决,零成本,多少年前就很成熟了。
这个项目的名字叫surya,是一个OCR识别项目,开源地址是 github.com/VikParuchuri/surya ,目前在github上拥有14K个star。它支持本地化部署,面对年收入低于500万美元的商用,可免费。
我在自己电脑上搭了一套,CPU就可以运行,GPU效率更高。稍微试验了下,给大家展示下功能。
一、功能展示
我拿这张图来进行试验,这是某报纸中的一篇新闻稿件。
它可以检测出图中有哪些类型的结构。比如,段落、图片、标题等。下面的图,就是将检测到的区域标记展示。
另外,区域检测还附赠一个阅读顺序的功能。所谓阅读顺序,就是你读这篇文档时,先读哪里后读哪里。比如按照从左到右,从上到下的顺序。有时候,阅读顺序也很重要,不然容易剧透。
既然是OCR,那么必定要将图像转为文字。想要转换文字,首先得知道哪些区域是文字。这个文本行检测,它也有。
检测到文字的位置,下一步就是识别文字了。下面是识别的结果。
最后,展示一下,它的表格识别。测试图片这样。
做一下表格检测,效果如下。
从识别到的数据信息看,它有4行,3列,12个单元格。
再来进行ocr内容识别。
二、算法集成
上面是它的功能。咱先不谈效果。下面我想问,它为什么能做到这些?回答完这个问题,才能更好地理解它的水平。
作者在最后列举了很多感谢,他说不借助这么多优秀的开源模型,他不可能完成这个项目。比如感谢CRAFT项目,这是一个超3k star的开源文本检测模型。
再比如它还采用了donut,这是一个利用无OCR理解文档的新方法。我们知道,想要理解文档,一般先要知道它上面写了什么,然后对文档进行分析,才能做出判断。而Donut结合多模态直接解析图像,极少处理文字,省去了全文分析的这个步骤。
看上面这张图。你问donut这张图的标题是什么?它可以正确地回答出来。这就是对文档的理解。
因此,从算法层面上,surya是借助了很多顶级的开源模型。而那些模型,也是站在巨人的肩膀上。可以说,它集成的算法是目前公开的一流水平。
我们再来说它的训练数据。他的训练数据,我们可以在 huggingface.co/vikp 上找到。
三、训练数据
比如文本区域类型检测,它的训练数据是这样的:
我们来看它的其中一组数据。image是一张图,bboxes是区域框,labels是区域类型,有文本类型,有表格类型。这些数据,是需要标注的,也就是在图片上画框,标注出区域类型。训练总量是1910张图片。不多。
比如表格的分析检测,它的训练数据是这样的:
image是一张表格图片,bboxes是单元格,rows是每一行的区域,cols是每一列的区域。把这些标记好的数据交给算法,让它学习什么样的特征的是行,什么样的是列。数据相对多一些,9680张图片。所以人家说它的表格识别很强。
对于文本行的检测,它的训练数据是这样的:
训练数据的结构组成:图片,图片中的某个区域,此区域对应的文本类型,另外还附加了一个文本内容。就拿上图选中的那条数据来说。这里面只标记了一条文本行区域。它告诉模型,这张图里面有一个类型为7的文本行,其区域是[88, 96, 865, 134](左、上、右、下)围成的矩形,请认真学习。
最后到了,OCR识别了。
训练数据的组成还是老一套,图片,区域框,文本内容。主要是告诉模型,我的这张图里面有几个区域,这些区域是什么文本内容,请你仔细学习。另外,这里面还有一个language字段,这表示文字的语言类型。
surya自称支持90多种语言的识别。这不是胡说,因为他的训练数据里,确实有90多种语言的标注。但是,总量太少了。一共4635张图片,如果平均的话,每种语言只有50来张训练数据。
因此,其实surya对于中文的OCR识别效果不是特别好(虽然自称并肩Tesseract)。其主要原因并不是算法不好,而是中文的训练数据太少。英文26个字母,50张纸可以覆盖。但是中文几万字,很难覆盖全。而对于手写识别,surya只能说随缘,因为根本没有训练数据,识别成啥样就是啥样。
这里面训练数据最多的是表格的识别,共9700张样本。训练数据最少的是阅读顺序的检测,才126张。因此,数据量决定了它的识别效果。对于海量的训练数据,就算对强大的商业公司来说也是一个难题。而作者能够凑足这几千张数据,已然很不容易了。
最终,我可以下一个结论。对于surya,它是一流的算法开源项目,是免费的。它对于表格的分析确实很强大。但是,它的训练数据还是太少,而且主要用于识别电子版的文档(清晰、无扭曲),手写识别基本不支持。如果你想要不做改动直接平替收费的OCR,可行性不高。就算是只用表格识别,你也得有稍微专业一些的人,进行从拍照到预处理成电子版的工作。如果收费的效果都不好,你想用免费替换它,可以打消这个念头。算法是开源的,但是训练数据和训练设备的投入,总得有人出。
如果,你想要学习并调整它,或者想自己训练,那么可以接着往下看。
四、源码运行
我不会讲官方ReadMe.md文档上明明白白写着的内容。比如你需要运行pip install streamlit
。或者它有这么几个参数,第一个--langs
是用于指定OCR的语言。
这样的话,我很像一个复读机。
另外,既然你都想要研究它了,应该不至于按照操作都跑不起来。你去看看它的源码,我只讲关键点。
首先,下载源码。你在源码中可以看到两个文件pyproject.toml
和poetry.lock
。这说明surya用的是poetry作为项目管理工具。poetry既能管理依赖包,也能管理虚拟环境。
咱们最好找一个Linux环境,再安装poetry。即便你在windows下,现在也可以很简单地安装个ubuntu虚拟机。因为linux实在是可以避免很多问题。
打开liunx命令行,进入到源码根目录。先运行pip install poetry
,安装poetry。再运行poetry install
安装依赖环境。最后运行poetry shell
进入环境,你会看到:
(surya-ocr-py3.12) root@tf:/mnt/d/surya#
这时运行surya_gui
,会启动它的web页面。正常情况下,你会看到如下的输出:
https://huggingface.co/vikp
(surya-ocr-py3.12) root@tf:/mnt/d/surya# surya_gui
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
Network URL: http://192.168.1.109:8501
gio: http://localhost:8501: Operation not supported
Loaded detection model /mnt/d/surya/vikp/surya_det3 on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_rec2 on device cpu with dtype torch.float32
Loaded detection model /mnt/d/surya/vikp/surya_layout3 on device cpu with dtype torch.float32
Loaded reading order model /mnt/d/surya/vikp/surya_order on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_tablerec on device cpu with dtype torch.float32
访问localhost:8501还有这样的页面:
但实际上,极有可能不正常。因为它在自动下载权重模型访问 huggingface.co 时会访问失败。这时,需要你想办法手动下载模型,然后放到一个固定的位置。
从报错信息能看到说加载不到模型。跟着代码就找到了surya/settings.py
。
# Text detection
DETECTOR_MODEL_CHECKPOINT: str = "vikp/surya_det3"
DETECTOR_BENCH_DATASET_NAME: str = "vikp/doclaynet_bench"
# Text recognition
RECOGNITION_MODEL_CHECKPOINT: str = "vikp/surya_rec2"
RECOGNITION_BENCH_DATASET_NAME: str = "vikp/rec_bench"
# Layout
LAYOUT_MODEL_CHECKPOINT: str = "vikp/surya_layout3"
LAYOUT_BENCH_DATASET_NAME: str = "vikp/publaynet_bench"
# Ordering
ORDER_MODEL_CHECKPOINT: str = "vikp/surya_order"
ORDER_BENCH_DATASET_NAME: str = "vikp/order_bench"
# Table Rec
TABLE_REC_MODEL_CHECKPOINT: str = "vikp/surya_tablerec"
TABLE_REC_BENCH_DATASET_NAME: str = "vikp/fintabnet_bench"
……
这里面是它5大功能(检测、识别、类型、排序、表格)的权重模型以及训练数据集的路径配置。正常情况下,会自动下载并缓存读取。但现在我们要自己下载并手动配置。下载方式就是去 huggingface.co/vikp 上找对应的模型文件。
用哪个就下载哪个模型文件,即用什么功能就下载什么功能。其实,对于新手来说,这并不好区分。因为有些功能是相互依赖的。比如表格识别,往往需要先检测出表格区域,才能识别行列区域。实际上会走好几个模型。因此,不熟悉的时候,把MODEL_CHECKPOINT
全下载就行了。
DATASET_NAME
是数据集,如果你要重新训练,就下载它。不调用训练的代码,不下载也不报错。
你可以把权重文件下载到项目的根目录。然后做如下的配置:
将"vikp/surya_det3"
改为os.path.join(BASE_DIR, "vikp/surya_det3")
。因上面定义了BASE_DIR是项目根目录,所以这个路径没错。
后面再运行surya_gui
就正常了。
访问 localhost:8501 可以上传文件进行5大功能的测试。
它会展示相应的结果。
而在控制台,也会输出操作类型和时间消耗:
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.61s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.06s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.44s/it]
Recognizing tables: 100%|███████| 1/1 [00:01<00:00, 1.19s/it]
这样,你就可以研究它的源码了。你可以改一点代码,运行一下,查看变化。具体的功能模块和代码对应,官方readMe.md上有说明。不管是暴露接口能力,还是修改内部函数,或者重新训练自己的数据,都有了着手点。
五、总结
优秀的开源项目就像一个质量很好的毛坯房,相对于商业软件,它往往不具备舒适的居住条件。但是,它的底子很好,结构合理,质量精良。它想发达,需要有人去做一个精装修。但是反过来,有些商业软件去了精装修,很可能就是豆腐渣框架。
为什么说现在是数据为王的时代。从上面的论述可以发现,在一定时空内,算法是公开的,算力投钱就可以,可能就是数据难搞。有好的、大量的数据投喂,才能产生好的AI模型。
来源:juejin.cn/post/7436713044246806578
前端如何做截图?
一、 背景
页面截图功能在前端开发中,特别是营销场景相关的需求中, 是比较常见的。比如截屏分享,相对于普通的链接分享,截屏分享具有更丰富的展示、更多的信息承载等优势。最近在需求开发中遇到了相关的功能,所以调研了相关的实现和原理。
二、相关技术
前端要实现页面截图的功能,现在比较常见的方式是使用开源的截图npm库,一般使用比较多的npm库有以下两个:
- dom-to-image: github.com/tsayen/dom-…
- html2canvas: github.com/niklasvh/ht…
以上两种常见的npm库,对应着两种常见的实现原理。实现前端截图,一般是使用图形API重新绘制页面生成图片,基本就是SVG(dom-to-image)和Canvas(html2canvas)两种实现方案,两种方案目标相同,即把DOM转为图片,下面我们来分别看看这两类方案。
三、 dom-to-image
dom-to-image库主要使用的是SVG实现方式,简单来说就是先把DOM转换为SVG然后再把SVG转换为图片。
(一)使用方式
首先,我们先来简单了解一下dom-to-image提供的核心api,有如下一些方法:
- toSvg (dom转svg)
- toPng (dom转png)
- toJpeg (dom转jpg)
- toBlob (dom转二进制格式)
- toPixelData (dom转原始像素值)
如需要生成一张png的页面截图,实现代码如下:
import domtoimage from "domtoimage"
const node = document.getElementById('node');
domtoimage.toPng(node,options).then((dataUrl) => {
const img = new Image();
img.src = dataUrl;
document.body.appendChild(img);
})
toPng方法可传入两个参数node和options。
node为要生成截图的dom节点;options为支持的属性配置,具体如下:filter,backgroundColor,width,height,style,quality,imagePlaceholder,cacheBust。
(二)原理分析
dom to image的源码代码不是很多,总共不到千行,下面就拿toPng方法做一下简单的源码解析,分析一下其实现原理,简单流程如下:
整体实现过程用到了几个函数:
- toPng(调用draw,实现canvas=>png )
- Draw(调用toSvg,实现dom=>canvas)
- toSvg(调用cloneNode和makeSvgDataUri,实现dom=>svg)
- cloneNode(克隆处理dom和css)
- makeSvgDataUri(实现dom=>svg data:url)
- toPng
toPng函数比较简单,通过调用draw方法获取转换后的canvas,利用toDataURL转化为图片并返回。
function toPng(node, options) {
return draw(node, options || {})
.then((canvas) => canvas.toDataURL());
}
- draw
draw函数首先调用toSvg方法获得dom转化后的svg,然后将获取的url形式的svg处理成图片,并新建canvas节点,然后借助drawImage()方法将生成的图片放在canvas画布上。
function draw(domNode, options) {
return toSvg(domNode, options)
// 拿到的svg是image data URL, 进一步创建svg图片
.then(util.makeImage)
.then(util.delay(100))
.then((image) => {
// 创建canvas,在画布上绘制图像并返回
const canvas = newCanvas(domNode);
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
});
// 新建canvas节点,设置一些样式的options参数
function newCanvas(domNode) {
const canvas = document.createElement("canvas");
canvas.width = options.width || util.width(domNode);
canvas.height = options.height || util.height(domNode);
if (options.bgcolor) {
const ctx = canvas.getContext("2d");
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
}
- toSvg
- toSvg函数实现从dom到svg的处理,大概步骤如下:
- 递归去克隆dom节点(调用cloneNode函数)
- 处理字体,获取所有样式,找到所有的@font-face和内联资源,解析并下载对应的资源,将资源转为dataUrl给src使用。把上面处理完的css rules放入中,并把标签加入到clone的节点中去。
- 处理图片,将img标签的src的url和css中backbround中的url,转为dataUrl使用。
- 获取dom节点转化的dataUrl数据(调用makeSvgDataUri函数)
function toSvg(node, options) {
options = options || {};
// 处理imagePlaceholder、cacheBust值
copyOptions(options);
return Promise.resolve(node)
.then((node) =>
// 递归克隆dom节点
cloneNode(node, options.filter, true))
// 把字体相关的csstext放入style
.then(embedFonts)
// clone处理图片,将图片链接转换为dataUrl
.then(inlineImages)
// 添加options里的style放入style
.then(applyOptions)
.then((clone) =>
// node节点转化成svg
makeSvgDataUri(clone,
options.width || util.width(node),
options.height || util.height(node)));
// 处理一些options的样式
function applyOptions(clone) {
...
return clone;
}
}
- cloneNode
cloneNode函数主要处理dom节点,内容比较多,简单总结实现如下:
- 递归clone原始的dom节点,其中, 其中如果有canvas将转为image对象。
- 处理节点的样式,通过getComputedStyle方法获取节点元素的所有CSS属性的值,并将这些样式属性插入新建的style标签上面, 同时要处理“:before,:after”这些伪元素的样式, 最后处理输入内容和svg。
function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then((clone) => cloneChildren(node, clone, filter))
.then((clone) => processClone(node, clone));
function makeNodeCopy(node) {
// 将canvas转为image对象
if (node instanceof HTMLCanvasElement) return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 递归clone子节点
function cloneChildren(original, clone, filter) {
const children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter)
.then(() => clone);
function cloneChildrenInOrder(parent, children, filter) {
let done = Promise.resolve();
children.forEach((child) => {
done = done
.then(() => cloneNode(child, filter))
.then((childClone) => {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(() => clone);
// 克隆节点上的样式。
function cloneStyle() {
...
}
// 提取伪类样式,放到css
function clonePseudoElements() {
...
}
// 处理Input、TextArea标签
function copyUserInput() {
...
}
// 处理svg
function fixSvg() {
...
}
}
}
- makeSvgDataUri
首先,我们需要了解两个特性:
- SVG有一个元素,这个元素的作用是可以在其中使用具有其它XML命名空间的XML元素,换句话说借助标签,我们可以直接在SVG内部嵌入XHTML元素,举个例子:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>
可以看到标签里面有一个设置了xmlns=“http://www.w3.org/1999/xhtml”…标签,此时标签及其子标签都会按照XHTML标准渲染,实现了SVG和XHTML的混合使用。
- XMLSerializer对象能够把一个XML文档或Node对象转化或“序列化”为未解析的XML标记的一个字符串。
基于以上特性,我们再来看一下makeSvgDataUri函数,该方法实现node节点转化为svg,就用到刚刚提到的两个重要特性。
首先将dom节点通过
XMLSerializer().serializeToString() 序列化为字符串,然后在
标签 中嵌入转换好的字符串,foreignObject 能够在 svg
内部嵌入XHTML,再将svg处理为dataUrl数据返回,具体实现如下:
function makeSvgDataUri(node, width, height) {
return Promise.resolve(node)
.then((node) => {
// 将dom转换为字符串
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
return new XMLSerializer().serializeToString(node);
})
.then(util.escapeXhtml)
.then((xhtml) => `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`)
// 转化为svg
.then((foreignObject) =>
// 不指定xmlns命名空间是不会渲染的
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${
foreignObject}</svg>`)
// 转化为data:url
.then((svg) => `data:image/svg+xml;charset=utf-8,${svg}`);
}
四、 html2canvas
html2canvas库主要使用的是Canvas实现方式,主要过程是手动将dom重新绘制成canvas,因此,它只能正确渲染可以理解的属性,有许多CSS属性无法正确渲染。
支持的CSS属性的完整列表:
html2canvas.hertzen.com/features/
浏览器兼容性:
Firefox 3.5+ Google Chrome Opera 12+ IE9+ Edge Safari 6+
官方文档地址:
html2canvas.hertzen.com/documentati…
(一)使用方式
// dom即是需要绘制的节点, option为一些可配置的选项
import html2canvas from 'html2canvas'
html2canvas(dom, option).then(canvas=>{
canvas.toDataURL()
})
常用的option配置:
全部配置文档:
html2canvas.hertzen.com/configurati…
(二)原理分析
html2canvas的内部实现相对dom-to-image来说要复杂一些, 基本原理是读取DOM元素的信息,基于这些信息去构建截图,并呈现在canvas画布中。
其中重点就在于将dom重新绘制成canvas的过程,该过程整体的思路是:遍历目标节点和目标节点的子节点,遍历过程中记录所有节点的结构、内容和样式,然后计算节点本身的层级关系,最后根据不同的优先级绘制到canvas画布中。
由于html2canvas的源码量比较大,可能无法像dom-to-image一样详细的分析,但还是可以大致了解一下整体的流程,首先可以看一下源码中src文件夹中的代码结构,如下图:
简单解析一下:
- index:入口文件,将dom节点渲染到一个canvas中,并返回。
- core:工具函数的封装,包括对缓存的处理函数、Context方法封装、日志模块等。
- css:对节点样式的处理,解析各种css属性和特性,进行处理。
- dom:遍历dom节点的方法,以及对各种类型dom的处理。
- render:基于clone的节点生成canvas的处理方法。
基于以上这些核心文件,我们来简单了解一下html2canvas的解析过程, 大致的流程如下:
- 构建配置项
在这一步会结合传入的options和一些defaultOptions,生成用于渲染的配置数据renderOptions。在过程中会对配置项进行分类,比如resourceOptions(资源跨域相关)、contextOptions(缓存、日志相关)、windowOptions(窗口宽高、滚动配置)、cloneOptions(对指定dom的配置)、renderOptions(render结果的相关配置,包括生成图片的各种属性)等,然后分别将各类配置项传到下接下来的步骤中。
- clone目标节点并获取样式和内容
在这一步中,会将目标节点到指定的dom解析方法中,这个过程会clone目标节点和其子节点,获取到节点的内容信息和样式信息,其中clone dom的解析方法也是比较复杂的,这里不做详细展开。获取到目标节点后,需要把克隆出来的目标节点的dom装载到一个iframe里,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
- 解析目标节点
目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为Canvas可以使用的数据类型。在对目标节点的解析方法中,递归整个DOM树,并取得每一层节点的数据,对于每一个节点而言需要绘制的部分包括边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。在整个解析过程中,对目标节点的所有属性进行解析构造,转化成为指定的数据格式,基础数据格式可见以下代码:
class ElementContainer {
// 所有节点上的样式经过转换计算之后的信息
readonly styles: CSSParsedDeclaration;
// 节点的文本节点信息, 包括文本内容和其他属性
readonly textNodes: TextContainer[] = [];
// 当前节点的子节点
readonly elements: ElementContainer[] = [];
// 当前节点的位置信息(宽/高、横/纵坐标)
bounds: Bounds;
flags = 0;
...
}
具体到不同类型的元素如图片、IFrame、SVG、input等还会extends ElementContainer拥有自己的特定数据结构,在此不详细贴出。
- 构建内部渲染器
把目标节点处理成特定的数据结构之后,就需要结合Canvas调用渲染方法了,Canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层,那么这个规则是什么样的呢?这里就涉及到CSS布局相关的一些知识。
默认情况下,CSS是流式布局的,元素与元素之间不会重叠。不过有些情况下,这种流式布局会被打破,比如使用了浮动(float)和定位(position)。因此需要需要识别出哪些脱离了正常文档流的元素,并记住它们的层叠信息,以便正确地渲染它们。
那些脱离正常文档流的元素会形成一个层叠上下文。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的规则,具体规则如下:
在了解了元素的渲染需要遵循这个标准后,Canvas绘制节点的时候,需要生成指定的层叠数据,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级,构造出所有节点对应的层叠上下文在内部所表现出来的数据结构,具体数据结构如下:
// 当前元素
element: ElementPaint;
// z-index为负, 形成层叠上下文
negativeZIndex: StackingContext[];
// z-index为0、auto、transform或opacity, 形成层叠上下文
zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext[];
// 定位和z-index形成的层叠上下文
positiveZIndex: StackingContext[];
// 没有定位和float形成的层叠上下文
nonPositionedFloats: StackingContext[];
// 没有定位和内联形成的层叠上下文
nonPositionedInlineLevel: StackingContext[];
// 内联节点
inlineLevel: ElementPaint[];
// 不是内联的节点
nonInlineLevel: ElementPaint[];
基于以上数据结构,将元素子节点分类,添加到指定的数组中,解析层叠信息的方式和解析节点信息的方式类似,都是递归整棵树,收集树的每一层的信息,形成一颗包含层叠信息的层叠树。
- 绘制数据
基于上面两步构造出的数据,就可以开始调用内部的绘制方法,进行数据处理和绘制了。使用节点的层叠数据,依据浏览器渲染层叠数据的规则,将DOM元素一层一层渲染到canvas中,其中核心具体源码如下:
async renderStackContent(stack: StackingContext): Promise<void> {
if (contains(stack.element.container.flags, FLAGS.DEBUG_RENDER)) {
debugger;
}
// 1. the background and borders of the element forming the stacking context.
await this.renderNodeBackgroundAndBorders(stack.element);
// 2. the child stacking contexts with negative stack levels (most negative first).
for (const child of stack.negativeZIndex) {
await this.renderStack(child);
}
// 3. For all its in-flow, non-positioned, block-level descendants in tree order:
await this.renderNodeContent(stack.element);
for (const child of stack.nonInlineLevel) {
await this.renderNode(child);
}
// 4. All non-positioned floating descendants, in tree order. For each one of these,
// treat the element as if it created a new stacking context, but any positioned descendants and descendants
// which actually create a new stacking context should be considered part of the parent stacking context,
// not this new one.
for (const child of stack.nonPositionedFloats) {
await this.renderStack(child);
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
for (const child of stack.nonPositionedInlineLevel) {
await this.renderStack(child);
}
for (const child of stack.inlineLevel) {
await this.renderNode(child);
}
// 6. All positioned, opacity or transform descendants, in tree order that fall int0 the following categories:
// All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order.
// For those with 'z-index: auto', treat the element as if it created a new stacking context,
// but any positioned descendants and descendants which actually create a new stacking context should be
// considered part of the parent stacking context, not this new one. For those with 'z-index: 0',
// treat the stacking context generated atomically.
//
// All opacity descendants with opacity less than 1
//
// All transform descendants with transform other than none
for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
await this.renderStack(child);
}
// 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index
// order (smallest first) then tree order.
for (const child of stack.positiveZIndex) {
await this.renderStack(child);
}
}
在renderStackContent方法中,首先对元素本身调用renderNodeContent和renderNodeBackgroundAndBorders进行渲染处理。
然后处理各个分类的子元素,如果子元素形成了层叠上下文,就调用renderStack方法,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。
如果子元素是正常元素没有形成层叠上下文,就直接调用renderNode,renderNode包括两部分内容,渲染节点内容和渲染节点边框背景色。
async renderNode(paint: ElementPaint): Promise<void> {
if (paint.container.styles.isVisible()) {
// 渲染节点的边框和背景色
await this.renderNodeBackgroundAndBorders(paint);
// 渲染节点内容
await this.renderNodeContent(paint);
}
}
其中renderNodeContent方法是渲染一个元素节点里面的内容,其可能是正常元素、文字、图片、SVG、Canvas、input、iframe,对于不同的内容也会有不同的处理。
以上过程,就是html2canvas的整体内部流程,在了解了大致原理之后,我们再来看一个更为详细的源码流程图,对上述流程进行一个简单的总结。
五、 常见问题总结
在使用html2canvas的过程中,会有一些常见的问题和坑,总结如下:
(一)截图不全
要解决这个问题,只需要在截图之前将页面滚动到顶部即可:
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
(二)图片跨域
插件在请求图片的时候会有图片跨域的情况,这是因为,如果使用跨域的资源画到canvas中,并且资源没有使用CORS去请求,canvas会被认为是被污染了,canvas可以正常展示,但是没办法使用toDataURL()或者toBlob()导出数据,详情可参考:developer.mozilla.org/en-US/docs/…
解决方案:在img标签上设置crossorigin,属性值为anonymous,可以开启CROS请求。当然,这种方式的前提还是服务端的响应头Access-Control-Allow-Origin已经被设置过允许跨域。如果图片本身服务端不支持跨域,可以使用canvas统一转成base64格式,方法如下。
function getUrlBase64_pro( len,url ) {
//图片转成base64
var canvas = document.createElement("canvas"); //创建canvas DOM元素
var ctx = canvas.getContext("2d");
return new Promise((reslove, reject) => {
var img = new Image();
img.crossOrigin = "Anonymous";
img.onload = function() {
canvas.height = len;
canvas.width = len;
ctx.drawImage(img, 0, 0, len, len);
var dataURL = canvas.toDataURL("image/");
canvas = null;
reslove(dataURL);
};
img.onerror = function(err){
reject(err)
}
img.src = url;
});
}
(三)截图与当前页面有区别
方式一:如果要从渲染中排除某些elements,可以向这些元素添加data-html2canvas-ignore属性,html2cnavas会将它们从渲染中排除,例如,如果不想截图iframe的部分,可以如下:
html2canvas(ele,{
useCORS: true,
ignoreElements: (element: any) => {
if (element.tagName.toLowerCase() === 'iframe') {
return element;
}
return false;
},
})
方式二:可以将需要转化成图片的部分放在一个节点内,再把整个节点,透明度设置为0, 其他部分层级设置高一些,即可实现截图指定区域。
六、 小结
本文针对前端截图实现的方式,对两个开源库dom-to-image和html2canvas的使用和原理进行了简单的使用方式、实现原理方面,进行介绍和分析。
参考资料:
1.dom-to-image原理
2.html2image原理简述
3.浏览器端网页截图方案详解
4.html2canvas
5.html2canvas实现浏览器截图的原理(包含源码分析的通用方法)
来源:juejin.cn/post/7400319811358818340
推荐一个小而全的第三方登录开源组件
大家好,我是 Java陈序员
。
我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。
为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那么有没有开源框架来统一来集成这些第三方授权登录呢?
答案是有的,今天给大家介绍的项目提供了一个第三方授权登录的工具类库!
项目介绍
JustAuth
—— 一个第三方授权登录的工具类库,可以让你脱离繁琐的第三方登录 SDK,让登录变得So easy!
JustAuth
集成了诸如:Github、Gitee、微博、钉钉、百度、Coding、腾讯云开发者平台、OSChina、支付宝、QQ、微信、淘宝、Google、Facebook、抖音、领英、小米、微软、今日头条、Teambition、StackOverflow、Pinterest、人人、华为、企业微信、酷家乐、Gitlab、美团、饿了么、推特、飞书、京东、阿里云、喜马拉雅、Amazon、Slack和 Line 等第三方平台的授权登录。
功能特色:
- 丰富的 OAuth 平台:支持国内外数十家知名的第三方平台的 OAuth 登录。
- 自定义 state:支持自定义 State 和缓存方式,开发者可根据实际情况选择任意缓存插件。
- 自定义 OAuth:提供统一接口,支持接入任意 OAuth 网站,快速实现 OAuth 登录功能。
- 自定义 Http:接口 HTTP 工具,开发者可以根据自己项目的实际情况选择相对应的HTTP工具。
- 自定义 Scope:支持自定义 scope,以适配更多的业务场景,而不仅仅是为了登录。
- 代码规范·简单:JustAuth 代码严格遵守阿里巴巴编码规约,结构清晰、逻辑简单。
安装使用
回顾 OAuth 授权流程
参与的角色
Resource Owner
资源所有者,即代表授权客户端访问本身资源信息的用户(User),也就是应用场景中的“开发者A”Resource Server
资源服务器,托管受保护的用户账号信息,比如 Github
Authorization Server 授权服务器,验证用户身份然后为客户端派发资源访问令牌,比如 GithubResource Server
和Authorization Server
可以是同一台服务器,也可以是不同的服务器,视具体的授权平台而有所差异Client
客户端,即代表意图访问受限资源的第三方应用
授权流程
使用步骤
1、申请注册第三方平台的开发者账号
2、创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)
3、使用 JustAuth
实现授权登陆
引入依赖
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>{latest-version}</version>
</dependency>
调用 API
// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);
说明:
JustAuth
的核心就是一个个的 request,每个平台都对应一个具体的 request 类。
所以在使用之前,需要就具体的授权平台创建响应的 request.如示例代码中对应的是 Gitee 平台。
集成国外平台
国外平台需要额外配置
httpConfig
AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());
SpringBoot 集成
引入依赖
<dependency>
<groupId>com.xkcoding.justauth</groupId>
<artifactId>justauth-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>
配置文件
justauth:
enabled: true
type:
QQ:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/qq/callback
union-id: false
WEIBO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/weibo/callback
GITEE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitee/callback
DINGTALK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/dingtalk/callback
BAIDU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/baidu/callback
CSDN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/csdn/callback
CODING:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/coding/callback
coding-group-name: xx
OSCHINA:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/oschina/callback
ALIPAY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/alipay/callback
alipay-public-key: MIIB**************DAQAB
WECHAT_OPEN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_open/callback
WECHAT_MP:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_mp/callback
WECHAT_ENTERPRISE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_enterprise/callback
agent-id: 1000002
TAOBAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/taobao/callback
GOOGLE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/google/callback
FACEBOOK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/facebook/callback
DOUYIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/douyin/callback
LINKEDIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/linkedin/callback
MICROSOFT:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/microsoft/callback
MI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/mi/callback
TOUTIAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/toutiao/callback
TEAMBITION:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/teambition/callback
RENREN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/renren/callback
PINTEREST:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/pinterest/callback
STACK_OVERFLOW:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/stack_overflow/callback
stack-overflow-key: asd*********asd
HUAWEI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/huawei/callback
KUJIALE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/kujiale/callback
GITLAB:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitlab/callback
MEITUAN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/meituan/callback
ELEME:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/eleme/callback
TWITTER:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/twitter/callback
XMLY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/xmly/callback
# 设备唯一标识ID
device-id: xxxxxxxxxxxxxx
# 客户端操作系统类型,1-iOS系统,2-Android系统,3-Web
client-os-type: 3
# 客户端包名,如果 clientOsType 为1或2时必填。对Android客户端是包名,对IOS客户端是Bundle ID
#pack-id: xxxx
FEISHU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/feishu/callback
JD:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/jd/callback
cache:
type: default
代码使用
@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class TestController {
private final AuthRequestFactory factory;
@GetMapping
public List<String> list() {
return factory.oauthList();
}
@GetMapping("/login/{type}")
public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
AuthRequest authRequest = factory.get(type);
response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
}
@RequestMapping("/{type}/callback")
public AuthResponse login(@PathVariable String type, AuthCallback callback) {
AuthRequest authRequest = factory.get(type);
AuthResponse response = authRequest.login(callback);
log.info("【response】= {}", JSONUtil.toJsonStr(response));
return response;
}
}
总结
JustAuth
集成的第三方授权登录平台,可以说是囊括了业界中大部分主流的应用系统。如国内的微信、微博、Gitee 等,还有国外的 Github、Google 等。可以满足我们日常的开发需求,开箱即用,可快速集成!
最后,贴上项目地址:
https://github.com/justauth/JustAuth
在线文档:
https://www.justauth.cn/
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7312060958175559743
fishhook--终于被我悟透了
fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook 又有了全新的认识。如果你跟我一样对 fishhook 的原理不甚了解那这篇文章会适合你。
需要强调的是本文不会从 fishhook 的使用基础讲起,也会不对照源码逐行讲解,只会着重对一些比较迷惑知识点进行重点阐述,建议先找一些相关系列文章进行阅读,补充一些基本知识再回过头来阅读本文。
注1:所有代码均以 64 位 CPU 架构为例,后文不再进行特别说明
注2:请下载 MachOView 打开任意 Mach-O 文件同步进行验证
注3:Mach-O 结构头文件地址
MachO 文件结构
0x01
Mach-O 文件结构有三部分,第一部分是 header,描述 Mach-O 文件的关键信息。其数据结构如下:
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
如上面结构体所示,Mach-O 文件 header 的关键信息包括:
cputype
:当前文件支持的 CPU 类型filetype
:当前 MachO 的文件类型ncmds
: Load Command 的数量sizeofcmds
:所有 Command 的总大小
每个 iOS 的可执行文件、动态库都会从 header 开始加载到内存中。
0x02
第二部分是 Load Commands,Load Commands 有不同的类型,有的用于描述不同类型数据结构(在文件中的位置、大小、类型、限权等),有的单纯用来记录信息,比如记录:dyld
的路径、main
函数的地址、UUID 等,用于记录信息的 Command 一般不会出现在数据区(Data)内。
不同的类型的 Load Command 对应着不同的结构体,但所有 Load Command 的前两个字段(cmd/cmdsize
)都是相同的。所以,所有的 Load Command 都可以通过类型强转为 load_command
结构体类型。
有了 load_command
就可以通过每一个 Load Command 的 cmdsize
计算出下一个 Load Command 的位置。
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize;` /* total size of command in bytes */
};
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
有文章说 load_command
是所有 Command 的基类,你也可以这样理解(虽然在代码语法层面不是)。
segment_command_64
作为一个 Load Command 重点类型,一般用来描述 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT
等包含实际代码数据的段(位于 Data 部分)。
因此对于 segment_command_64
类型的 Load Command 也称之为: segment。
segment
内部还包含一个重要的类型:section,section 用于描述一组相同类型的数据。例如:所有代码逻辑都位于名为 __text 的 section 内,所有 OC 类名称都位于名为 __objc_classname 的 section 内,而这两个 section 均位于 __TEXT 段(segment)。
segment_command_64
关键字段介绍:
segname
: 当前 segment 名称,可为 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 之一vmaddr
: 当前 segment 加载到内存后的虚拟地址(实际还要加上 ALSR 偏移才是真实的虚拟地址)vmsize
: 当前 segment 占用的虚拟内存大小fileoff
: 当前 segment 在 Mach-O 文件中的偏移量,实际位置 = header 开始的地址 + fileofffilesize
: 当前 segment 在 Mach-O 文件中的实际大小,考虑到内存对齐vmsize
>=filesize
nsects
: 当前segment_command_64
下面包含的 section 个数
关于随机地址偏移(ALSR) 的相关容内可自行查找相关资料进行了解,这里不再贅述
section 只有一种类型,其结构体定义如下:
struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_t size; /* size in bytes of this section */
uint32_t offset; /* file offset of this section */
uint32_t align; /* section alignment (power of 2) */
uint32_t reloff; /* file offset of relocation entries */
uint32_t nreloc; /* number of relocation entries */
uint32_t flags; /* flags (section type and attributes)*/
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
section 关键字段介绍:
sectname
: section 的名称,可以为 __text,__const,__bss 等segname
: 当前 section 所在 segment 名称addr
: 当前 section 在虚拟内存中的位置(实际还要加上 ALSR 偏移才是真实的虚拟地址)size
: 当前 section 所占据的大小(磁盘大小与内存大小)reserved1
: 不同 section 类型有不同的意义,一般代表偏移量与索引值flags
: 类型&属性标记位,fishhook 使用此标记查找懒加载表&非懒加载表
需要注意:有且仅有 segment_command_64
类型的 Command 包含 section。
0x03
最后是数据区(Data),就是 Mach-O 文件所包含的代码或者数据;所有代码或者数据都根据 Load Command 的描述进行组织、排列。其中由segment_command_64
描述的数据或代码在 Data 部分中均以 section 为最小单位进行组织,并且这部分内容占大头。segment 再加上其它类型 Load Command (其实就是 __LINKEDIT
segement)描述的数据共同组成了数据区。
注意:虽然名称为 __LINKEDIT
(类型为:segment_command_64
) 的 segment 下面所包含的 section 数量为 0,但根据其 fileoff,filesize
计算发现:
__LINKEDIT
的 segement 所指向的文件范围其实包含其它 Load Command (包含但不限于:LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE)所指向的位置范围。
推导过程如下:
如上图所示在 Load Commands 中 __LINKEDIT
在 Mach-O 文件的偏移:0x394000
大小为:0x5B510
。而 Mach-O header 的开始地址为 0x41C000
。所以 __LINKEDIT
在 Mach-O 文件中的地址范围是:{header + fileoffset, header + fileoffset + filesize}
,代入上式就是 {0x41C000+0x394000, 0x41C000+0x394000+0x5B510}
,最终得到 {0x7B0000,0x80B510}
的地址范围。
从下图看,segment 最后一个 section 结束后的第一个地址就是上面的开始的范围,文件的结束地址也是上面计算结果的结束范围(最后一个数据地址占 16 字节)。
所以可以这样理解:名称为 __LINKEDIT
Load Command 是一个虚拟 Command。它用来指示LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE 等这些 Command 描述的数据在「文件与内存」中的总范围,而这些 Command 自己本身又描述了自各的范围,从地址范围来看 __LINKEDIT
是这些 Command 在数据部分的父级,尽管它本身并没有 section。
fishhook 的四个关键表
fishhook 的实现原理涉及到四个「表」,理解这四个表之间的关系便能理解 fishhook 的原理,且保证过目不忘。
- 符号表(Symbol Table)
- 间接符号表(Indirect Symbol Table)
- 字符表(String Table)
- 懒加载和非懒加载表(__la_symbol_ptr/__non_la_symbol_ptr)
符号表&字符表
其中符号表(Symbol Table)与字符表(String Table)在 LC_SYMTAB 类型的 Load Command 中描述。
struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */
uint32_t symoff; /* 符号表(Symbol Table)在文件中相对 header 的偏移 */
uint32_t nsyms; /* 符号表(Symbol Table)数量 */
uint32_t stroff; /* 字符表(String Table)在文件中相对 header 的偏移 */
uint32_t strsize; /* 字符串(String Table)表总大小*/
};
符号表(Symbol Table)内容的数据结构用 nlist_64
表示:
struct nlist_64 {
union {
uint32_t n_strx; /* index int0 the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
nlist_64
的第一个成员 n_un
代表当前符号的名称在字符表(String Table)中的相对位置,其它成员变量这里不需关注。
字符表(String Table)是一连串的字符 ASCII 码数据,每个字符串之间用 '\0' 进行分隔。
间接符号表
而间接符号表(Indirect Symbol Table)在 dysymtab_command
结构体的 Load Command(类型为LC_DYSYMTAB)中描述。
struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */
/* 省略部分字段 */
uint32_t indirectsymoff; /* 间接符号表相对 header 的偏移 */
uint32_t nindirectsyms; /* 间接符号表中符号数量 */
/* 省略部分字段 */
};
间接符号表本质是由 int32
为元素组成的数组,元素中存储的数值代表当前符号在符号表(Symbol Table)中的相对位置。
懒加载和非懒加载表
懒加载与非懒加载表位于 __DATA/__DATA_CONST
segment 下面的 section 中。
懒加载与非懒加载表有如下特点:
- 当前可执行文件或动态库引用外部动态库符号时,调用到对应符号时会跳转到懒加载与非懒加载表指定的地址执行
- 懒加载表在符号第一次被调用时绑定,绑定之前指向桩函数,由桩函数完成符号绑定,绑定完成之后即为对应符号真实代码地址
- 非懒加载表在当前 Mach-O 被加载到内存时由 dyld 完成绑定,绑定之前非懒加载表内的值为 0x00,绑定之后同样也为对应符号的真实代码地址
敲黑板知识点:fishhook 的作用就是改变懒加载和非懒加载表内保存的函数地址
由于懒加载和非懒加载表没有包含任何符号字符信息,我们并不能直接通过懒加载表和非懒加载表找到目标函数在表中对应的位置,也就无法进行替换。因此,需要借助间接符号表(Indirect Symbol Table)、符号表(Symbol Table)、字符表(String Table)三个表之间的关系找到表中与之对应的符号名称来确认它的位置。
如何找到目标函数地址
这里借用一下 fishhook 官方给的示意图,可以先自行理解一下再往下看:
引用外部函数时需要通过符号名称来确定函数地址在懒加载和非懒加载表的位置,具体过程如下:
- 懒加载表与非懒加载表中函数地址的索引与间接符号表(Indirect Symbol Table)中的位置对应;
以表中第
i
个函数地址为例,对应关系可以用伪公式来表述:
间接符号表的偏移
=
间接符号表开始地址+
懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+
i
- 间接符号表中保存的 int32 类型的数组,以上一步计算到的「间接符号表的偏移」为索引取数组内的值得到符到号中的位置
同样得到一个等效伪公式:符号表的偏移
=
间接符号表开始地址+
间接符号表的偏移 - 符号表中保存的数据是
nlist_64
类型,该第一个字段(n_un.n_strx
)的值就是当前符号名称在字符表中的偏移
等效伪公式:符号名称在字符表中的偏移
= (
符号表的开始地址+
符号表的偏移).n_un.n_strx
- 按照上面得到的偏移,去字符表中取出对应字符串(以
\0
)结尾
等效伪公式:懒加载表与非懒加载表中第
i
个函数名=
字符表的开始地址+
符号名称在字符表中的偏移
到这里我们从下至上进行公式代入,合并三个伪公式得到:
懒加载表或非懒加载表中第 i
个函数名 =
字符表的开始地址 +
(
符号表的开始地址 +
间接符号表开始位置 +
懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+
i)
.n_un.n_strx
。
现在,上面这个公式里还不知道的是三个开始地址:
- 字符表(String Table)的开始地址
- 符号表(Symbol)的开始地址
- 间接符号表(Indirect Symbol Table)开始地址
而懒加载表或非懒加载表中函数地址个数也可以通过对应 section 的 size
字段(详情查看上文 section_64
结构体中的描述)计算而得到,公式:(section->size / sizeof(void *)
)。
到这里 fishhook 四个表的关系应该非常清楚了,fishhook 所做的无非是通过这个公式在懒加载表与非懒加载表中找到与目标函数名匹配的外部函数,一旦发现匹配则将其地址改为自定义的函数地址。
何为 linkedit_base
如果不考虑其它因素,实际上面三个表的开始地址可以直接通过 Mach-O 的 header 地址 + 对应的偏移就可以直接得到。以符号表(Symbol Table)为例:
Mach-O header 的开始地址如上文所述为:0x41C000,计算 0x41C000 + 0x3BECD8 = 0x7DACD8;再用 MachOView 查看这个地址,确实是符号表在文件中的位置:
同时上面的的推导也证明了 symtab_command->symoff
symtab_command->stroff
是相对 Mach-O header 的偏移,并不是相对 __LINKEDIT
的偏移;
而 fishhook 源码中计算符号表开始地址的方式是:
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
导致不少博文说 linkedit_base
是 __LINKEDIT 段的基地址,symoff 是相对 __LINKEDIT segment 的偏移,这完全是错误的,在此可以明确的是:
linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址linkedit_base
并不是__LINKEDIT
segment 在内存中的开始地址
fishhook 中计算 linkedit_base
的计算方式如下:
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
忽略掉随机地址偏移(ALSR)值: slide
后:
linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff
;
linkedit_segment->vmaddr
:代表 __LINKEDIT
segment 在「虚拟内存」中的相对开始位置
linkedit_segment->fileoff
:代表 __LINKEDIT
segment 在「文件」中的相对开始位置
那这两个相减有什么意义呢?
要解答这个问题先来看 MachOView 给出的信息:
如上图,在 __LINKEDIT
segment 之前的几个 segment (红框标记)可以解析出几个事实:
- 每个 segment 的在「 Mach-O 文件」中的开始地址都等于上一个 segment 的
File Offset + File Size
,第一个 segment 从 0 开始 - 同理,每个 segment 在「虚拟内存」中的位置都等于上一个 segment 的
VM Address + VM Size
,第一个 segment 从 0 开始 __PAGEZERO
与_DATA
的VM Size > File Size
,而其它 segment 中这两个值相等,意味着两个 segment 加载到虚拟内存中后有一部分「空位」(因内存对齐而出现)__PAGEZERO
不占 Mach-O 文件的存储空间,但在虚拟内存在占 16K 的空间
用图形表示即为:
故而 linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff
的意义为:
- Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位) - Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位) - Mach-O 加载到内存后
__LINKEDIT
前面的 segment 因内存对齐后多出来的空间(空位)
这才是 linkedit_base
在物理上的真正意义,任何其它的定义都是错误的。
而 __LINKEDIT
本身的 VM Size == File Size 说明它包含的符号表、字符表与间接符号表三个表本身是内存对齐的,它们之间没有空位,所以它们本身在文件中的偏移 +
linkedit_base
即为在内存中的实际位置。
// 符号表在内存中的开始位置
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// 字符表在内存中的开始位置
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 间接符号表在内存中的开始位置
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
最后
fishhook 在 APM、防逆向、性能优化等方向均有较多的应用,从本质上来看 fishhook 是对 Mach-O 文件结构的深度应用。相信在了解完原理之后再去看 Mach-O 文件的结构就比较简单了,与 Mach-O 文件结构相关的应用还有符号表的还原。下篇文章再与大家共同学习符号表还原的具体过程(虽然文件夹还没有创建 😂)。
如对本文有任何疑问,我们评论区交流 😀
来源:juejin.cn/post/7360980866796388362
为什么不建议使用多表join
前言
三年前在一家公司和开发团队的架构师合作过,一起写过代码。让我真的很难受啊,这个架构师写的代码很多逻辑都写到SQL
里面,各种连表查询,SQL
非常的复杂,每次我去维护都得看好久它这个SQL
逻辑。
回到最近,现在有个小伙儿班也是喜欢在SQL里面写逻辑,各种关联查询,甚至写的SQL连一万的数据连都支持不了。
都给我贡献了好几篇文章了:
完了演示的时候报错了!distinct 别乱用啊
sql 子查询的巨坑 ,80%的后端都不知道这个问题
所以我们的SQL
尽量的简洁,少用多表关联查询。
为什么不建议使用多表join?
最主要的原因就是join的效率比较低
MySQL是使用了嵌套循环(Nested-Loop Join)的方式来实现关联查询的,就是要通过两层循环,用第一张表做外循环,第二张表做内循环,外循环的每一条记录跟内循环中的记录作比较,符合条件的就输出。
- 性能问题:
- 多表
JOIN
会增加查询的复杂性,可能导致性能下降,特别是在数据量大时。 - 数据库需要在执行查询时处理更多的行和列,这可能导致更高的 I/O 操作和内存使用。
- 多表
- 可读性和维护性:
- 复杂的
JOIN
查询会使 SQL 语句变得难以理解,导致维护成本增加。 - 当查询需要频繁修改时,复杂的
JOIN
会让代码更容易出错。
- 复杂的
- 索引利用率:
- 多表
JOIN
可能会导致数据库无法有效利用索引,影响查询的优化。 - 如果
JOIN
的字段没有适当的索引,查询性能会显著下降。
- 多表
- 锁竞争:
- 多表
JOIN
可能导致更长时间的行锁或表锁,从而增加锁竞争的可能性,影响并发性能。
- 多表
- 数据完整性:
- 复杂的
JOIN
查询可能掩盖数据问题或不一致性,使得调试较为困难。 - 难以确保在
JOIN
查询中返回的数据符合业务逻辑和数据完整性要求。
- 复杂的
如何优化:
- 分解查询:在内存中自己做关联,即先从数据库中把数据查出来之后,再次查询,然后再进行数据封装。
- 考虑数据冗余:在某些情况下,可以考虑数据冗余来减少
JOIN
的需要。 - 宽表:就是基于一定的join关系,把数据库中多张表的数据打平做一张大宽表,可以同步到ES或者干脆直接在数据库中直接查都可以
什么是hash join
(扩展阅读)
mysql8.0 以前join查询使用Nested-Loop Join
算法实现
Nested-Loop Join:嵌套循环连接,如果有2张表join的话,复杂度最高是O(n^2),3张表则是O(n^3),表中的数据量越多,JOIN的效率会呈指数级下降。
MySQL 8.0中优化了join查询,新增了 hash join算法。
Hash Join 是一种高效的联表查询算法,通常用于处理较大数据集的连接操作。下面将详细介绍 Hash Join 的原理,并通过示例图解说明其查询步骤。
Hash Join 原理
Hash Join 的基本原理是将一个表的数据构建成一个哈希表,然后利用该哈希表来查找另一个表中匹配的行。其主要分为两个阶段:
- 构建阶段(Build Phase):
- 选择一个较小的表(称为构建表)来创建哈希表。
- 根据连接条件的键值计算哈希值,并将这些键值和对应的行存储在哈希表中。
- 探测阶段(Probe Phase):
- 对另一个表(称为探测表)逐行读取数据。
- 对于探测表中的每一行,计算连接字段的哈希值,并在哈希表中查找匹配的行。
- 如果找到匹配,则将匹配的行组合在一起,形成结果集。
Hash join 案例
假设我们有两个表:
表 A:
ID | Name |
---|---|
1 | Alice |
2 | Bob |
3 | Charlie |
表 B:
ID | Age |
---|---|
1 | 25 |
2 | 30 |
4 | 40 |
我们希望通过 ID 字段将这两个表连接起来。
步骤 1: 构建哈希表
选择表 A 作为构建表。我们将根据 ID 字段创建哈希表。
- 对于 ID = 1,哈希值为
hash(1)
,存储为{1: Alice}
。 - 对于 ID = 2,哈希值为
hash(2)
,存储为{2: Bob}
。 - 对于 ID = 3,哈希值为
hash(3)
,存储为{3: Charlie}
。
哈希表:
{
1: Alice,
2: Bob,
3: Charlie
}
步骤 2: 探测阶段
接下来,我们对表 B 进行探测,查找与哈希表中的行匹配的行。
- 对于 ID = 1,计算
hash(1)
,在哈希表中找到匹配,结果为(1, Alice, 25)
。 - 对于 ID = 2,计算
hash(2)
,在哈希表中找到匹配,结果为(2, Bob, 30)
。 - 对于 ID = 4,计算
hash(4)
,在哈希表中未找到匹配。
匹配之后做聚合就得到结果集了
这里的hash表是存在内存中的,内存是有限制的,超过阈值之后就会走 磁盘Hash join 的算法
磁盘hash join
如果驱动表中的数据量超过阈值,就会走磁盘hash join
的算法。将驱动表拆分成多个哈希区(或桶),每个桶存储在磁盘上。读取磁盘上的hash桶
分别加载到内存,进行探测匹配,探测完成释放当前内存桶,继续从磁盘上读取下一个hash
桶进行探测匹配,直到磁盘上所有的hash
桶都处理完毕。
总结
在实际开发中,尽量减少多表join
查询,保持SQL
的逻辑清晰,这样不仅能提高性能,还有利于维护。
感谢佬们的一键三连+关注 !!!
来源:juejin.cn/post/7438597251487268875
做了这么久前端,这些请求头和响应头的含义你都不知道啊
前言
如果你是一名开发,肯定对请求头和响应头这两个词听上去特别有亲切感,请求头和响应头顾名思义就是请求和响应相关的一些信息,但具体到请求头和响应头里面的某个参数是啥意思可能很多人就不知道了。
就像最近问到一些面试者响应头里面最常见的Cache-Control
和Content-Type
所代表的是什么意思,很多都回答的支支吾吾的。真要说在项目中这种面试者也肯定能正常搬砖干活,但一看就是基本功非常差,如果有对比选择的情况下非常容易被"pass"
掉。
因此这篇文章主要对比较常用的请求头和响应头进行解释,除了能应对面试官外还能对知识面进行扩展。
什么是请求头和响应头
简单说请求头和响应头就是HTTP
协议的组成部分,请求头和响应头用于在客户端(浏览器)和服务器之间携带传递额外的属性,这些属性内容会用于控制HTTP
请求和响应的行为。
其中请求头是客户端带给服务端,响应头是服务端带给客户端。
常见请求头含义
Accept:
含义:表示指定客户端能够接受哪些类型的内容。
当客户端用接口请求时,设置Accept
会告诉服务器要返回合适的类型格式。
示例
accept: application/json, text/plain,
Accept-Charset
含义: 表示指定客户端能够接受哪些类型的字符集。
Accept-Charset: utf-8, iso-8859-1;q=0.5
Cookie
含义: 表示用于存储用户特有信息,让用品去识别用户的具体身份。通过Cookie
传递用户ID
,让服务器端识别用户身份。
示例
Cookie: session=abPC9527; user=tty
Origin
含义: 表示跨域相关信息,用于设置CORS
的请求。通过Origin
头,防止陌生的域进行请求。
示例
Origin: https://tty.com
Referer
含义: 表示当前的请求是从哪个url
链接过来的。
示例
Referer: https://tty.com/pageone
User-Agent
含义: 表示包含发起请求的用户的一些代理信息,例如浏览器的具体版本和具体类型。
示例
User-Agent: Mozilla/3.0 (Windows NT 9.0; Win32; x64) AppleWebKit/517.36 (KHTML, like Gecko) Chrome/56.0.3029.110 Safari/517.3
If-Modified-Since
含义: 表示客户端在上次获取资源的具体时间。
示例
If-Modified-Since: Tue, 10 Oct 2021 11:01:01 GMT
Range
含义: 表示指定第一个字节到指定最后字节之间的位置,用于告诉服务器想取那个范围的数据。
示例
Range: bytes=0-255
常见响应头含义
Access-Control-Allow-Origin
含义: 表示用于配置CORS
跨域相关,指定允许访问资源的域名,如果配置为*
表示所有可访问。
示例
Access-Control-Allow-Origin: *
Cache-Control
含义: 表示缓存机制的缓存策略。
示例------这里面试重点
Cache-Control:public // 响应会被缓存
Cache-Control:must-revalidate // 指定条件下会缓存重用
Cache-Control:no-cache // 直接向服务器端请求最新资源,不缓存
Cache-Control:max-age=10 // 设置缓存的有效时间
Cache-Control:no-store // 在任何条件下,响应都不会被缓存
Content-Length
含义: 表示当前响应体的具体大小,具体单位为字节。
示例
Content-Length: 9527
Content-Type
含义: 表示响应体的具体数据格式是什么。
示例
Content-Type: application/json
Date
含义: 表示服务器开始对客户端发送响应的具体时间。
示例
Date: Tue, 10 Oct 2021 11:01:01 GMT
ETag
含义: 表示用于验证缓存,确保当前的资源未被修改过。如果没有更改过则返回304
状态码,减少不必要传输。
示例
ETag: "1234952790pc"
Location
含义: 表示用于重定向,指向一个新的URL
。
示例
Location: https://tty.com/new-page
Set-Cookie
含义: 表示服务器通过这个请求头把cookie
带到客户端。客户端会在后面请求中自动将这cookie
放在请求头中。
示例
Set-Cookie: session=pc9527; Path=/; HttpOnly; Secure
Server
含义: 表示告诉这个服务器软件的信息,例如版本。
示例
Server: Apache/1.4.38 (Ubuntu)
X-Powered-By
含义: 表示返回后端使用的具体框架或技术栈。
示例
X-Powered-By: Express
Content-Encoding
含义: 表示响应体的编码方式,例如gzip
压缩。
示例
Content-Encoding: gzip
Last-Modified
含义: 表示资源最后被修改的具体时间。
示例
Last-Modified: Tue, 10 Oct 2021 11:00:00 GMT
Expires
含义: 跟缓存相关,表示指定资源的过期时间,这个时间前都不过期。
示例
Expires: Wed, 21 Oct 2021 07:21:00 GMT
小结
这些内容看似好像日常写业务代码没咋用到,但其实是非常重要的,里面涉及到缓存、跨域和安全相关等等的内容。
这些内容足够验证一个开发知识面是否足够广。
好啦,以上就是比较常见的响应头和请求头的一些字段。如果哪里写的不对或者有更好有建议欢迎指出。
来源:juejin.cn/post/7438451242567319571
纯前端图片压缩神器 Compressor
点赞 + 关注 + 收藏 = 学会了
本文简介
现在大部分网站都会有图片,不管这个图片是用来展示的,还是需要上传到服务器的。
但图片的体积往往比文字大,会占用更多的服务器空间,也会消耗用户更多的流量。所以在适当范围内压缩一下图片是很有必要的。
今天介绍一款纯前端的图片压缩工具:compressor.js。
虽然这是一款有损的图片压缩工具,但压缩质量还是挺不错的,尤其是它可以在前端运行,对于要上传图片到服务器的业务,可以考虑一下用 compressor.js。
你也可以用 Compressor.js 做个图片压缩的工具网站,用户多了就开个百度或者谷歌的广告,也能赚点奶茶钱。
先体验一下 compressor.js 的效果:fengyuanchen.github.io/compressorj…
这是 compressor.js 的代码仓库:github.com/fengyuanche…
动手试试
安装 compressor
npm
npm 通过这条命令安装。
npm install compressorjs
然后在需要使用到 compressor.js 的页面中引入。
import Compressor from 'compressorjs';
CDN
如果你不使用打包工具,也可以直接通过 CDN 在 HTML 中引入 Compressor.js。
<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>
基础用法
要使用 compressor.js 压缩图片,首先通过 new Compressor
创建一个压缩实例,并传入文件和一些配置参数。成功后会返回一个压缩后的图片对象。
接下来我用一个小例子演示一下。这个例子通过上传一张图片,然后使用 compressor 压缩它,再返回一个下载链接。
<!-- 文件上传控件 -->
<input type="file" id="fileInput" accept="image/*">
<!-- 下载压缩后的图片 -->
<div id="downloadLink">
<a id="downloadCompressed" style="display:none;" download>点击下载压缩后的图片</a>
</div>
<!-- 引入 Compressor.js -->
<script src="https://cdn.jsdelivr.net/npm/compressorjs@latest/dist/compressor.min.js"></script>
<script>
// 获取 file input 和下载链接元素
const fileInput = document.getElementById('fileInput')
const downloadCompressed = document.getElementById('downloadCompressed')
// 当文件选择发生变化时触发
fileInput.addEventListener('change', function(event) {
const file = event.target.files[0] // 获取上传的文件
if (!file) {
return // 如果没有选择文件,则不继续执行
}
// 使用 Compressor.js 压缩图片
new Compressor(file, {
success(result) {
downloadCompressed.href = URL.createObjectURL(result)
// 显示下载链接
downloadCompressed.style.display = 'inline'
},
error(err) {
console.error('压缩失败:', err)
},
})
})
</script>
在这个例子中,使用了 <input type="file">
作为上传图片的元素,获取到用户上传的图片后,使用 new Compressor(file[, options])
来压缩图片,new Compressor
接收2个参数,第一个参数是图片文件,第二个参数是一系列参数,在本例中的所有参数都使用了默认值。最后通过 success()
处理压缩成功后的操作,用 error()
处理压缩失败后的操作。
当压缩成功后就进入 success(result)
里了,这里的 result
返回了压缩成功后的图片对象,通过 URL.createObjectURL(result)
的方式将返回压缩成功后的图片地址。将该地址赋值到 <a>
标签里就能给用户手动点击下载了。
挺简单吧~
配置压缩强度
在前面的例子中,我们通过 new Compressor(file[, options])
压缩图片,但压缩的强度默认是 80%,在压缩 JPG 时默认是 92%。如果你希望将图片体积压缩得更小(画质会更差),可以在 options
这个参数里配置一项 quality
。quality
接收的值是 0~1
,quality
的数值越小压缩出来的图片体积就越小,压缩力度就越大。
具体用法:
// 省略部分代码
new Compressor(file, {
quality: 0.6, // 设置压缩质量为 60%
success(result) {}, // 压缩成功后执行这里的代码
error(err) {} // 压缩失败后执行这里的代码
})
设置下载文件的文件名
在前面的例子中,我们下载压缩成功后的图片,文件名看上去是一堆乱码。
比如,我想将压缩后的图片名改成在原图的文件名后面拼上“-德育处主任”,可以这么做。
// 省略部分代码
new Compressor(file, {
quality: 0.6, // 设置压缩质量为 60%
success(result) {
// 获取文件名,并给压缩后的文件加上 "-德育处主任" 后缀
const originalName = file.name;
const extensionIndex = originalName.lastIndexOf('.');
const nameWithoutExtension = originalName.substring(0, extensionIndex);
const extension = originalName.substring(extensionIndex);
downloadCompressed.download = nameWithoutExtension + '-德育处主任' + extension;
downloadCompressed.href = URL.createObjectURL(result)
// 显示下载链接
downloadCompressed.style.display = 'inline'
},
error(err) {
console.error('压缩失败:', err)
}
})
压缩网络图片
compressor.js 的第一个参数必须是一个 File
对象(通常是通过文件上传获取的),它不支持直接传入网络图片的 URL。因为它需要操作的是一个本地的 File
或 Blob
对象,而不是通过 URL 获取的资源。
但我们可以先通过 JavaScript 将网络图片转换为一个 File
或 Blob
对象,然后再将其传递给 compressor.js。
我上传了一张图片到免费的图床上(这是将我公众号的url转成艺术二维码的图片): i.imgur.com/zyurGlf_d.w…
function urlToBlob(url) {
return fetch(url)
.then((response) => response.blob())
.then((blob) => {
// Step 2: 将 Blob 传递给 Compressor.js
new Compressor(blob, {
quality: 0.8, // 设置压缩质量
success(result) {
console.log('压缩后的图片:', result)
},
error(err) {
console.error('压缩出错:', err)
},
})
})
}
const imageUrl = 'https://i.imgur.com/zyurGlf_d.webp?maxwidth=760&fidelity=grand'
urlToBlob(imageUrl)
通过 fetch
读取这张图片,然后将读取回来的图片执行 .blob()
方法将其转换成 blob
再丢给 compressor.js 压缩。
以上就是本文的内容啦,如果你想在线体验一下 compressor.js 的压缩能力,可以试试这个网站 worklite.vip/
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7415912074993319976
uni-app 接入微信短剧播放器
前言
作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。
小程序申请插件
参考文档:developers.weixin.qq.com/miniprogram…
首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
uni-app 项目添加微信插件
参考文档:uniapp.dcloud.net.cn/tutorial/mp…
添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}
manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}
挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~
App.vue 配置
参考文档:developers.weixin.qq.com/miniprogram…
首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):
var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}
PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager
新建完成后,在 App.vue 中进行组件的配置和引用。
onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')
const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},
页面使用
参考文档:developers.weixin.qq.com/miniprogram…
以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:
clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},
写在最后:
总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取
// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')
读书越多越发现自己的无知,Keep Fighting!
欢迎友善交流,不喜勿喷~
Hope can help~
来源:juejin.cn/post/7373473695057428506
不能沉迷于无畏契约了,我要学axios-retry源码,以后遇到接口不响应就这么办!
前言
挺久没写文章,最近下班后都在打瓦罗兰特
,一直在黄金一和白银三徘徊,感觉已经要废了,所以也没啥时间写文章。工作上最近也是换了一个组,之前主要是干web
,现在是在写sass中台和h5
,然后也是负责一个小迭代,整体其实就是一个curd,但是也是遇到一些奇奇怪怪的坑,有一个我觉得还是很有含金量的,然后我是用了一个第三包解决的,然后也顺带去看了一下这个包的源码,也学到不少的东西,记录分享一下。
请求不响应后重新请求
在我现在这个项目中,对于一些请求,他的生命周期会比较长
。正常来说,我们只需要和一个服务端请求,服务端收到后就返回。但是这里是,前端对服务端a请求后,服务端a还要向服务端b去请求,服务器a只能等待服务器b响应后再给我们前端响应,所以就会存在请求不响应超时
的问题,如果是偶发性的还好,但是频率好像还挺高的,就是可能调同一个接口10次,有3次是不响应的。然后我这是将网络禁用去模拟的一个效果。
解决方案
解决方案也挺简单的,就是服务端a在5s内如果收不到服务端b的响应,就会给前端报timeout的错误,我这边如果收到timeout的错误就是重新请求
,指数型去重试请求5次,如果还是不成功就只能给用户提示“请求超时,请重新提交了”。
代码
axios-retry地址 http://www.npmjs.com/package/axi…
这里使用了axios-retry
,这个包就是可以二次封装axios实例去实现重新请求。正常来说,我们的项目中都会对axios进行封装,如下代码,去对请求拦截器和响应拦截器做一些公共处理。
import axios from 'axios'
const http = axios.create({
headers: {},
timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
http.interceptors.request.use(
(config) => {
console.log(config)
return config
},
(err) => {
console.log(err)
return Promise.reject(err)
}
)
// 响应拦截器
http.interceptors.response.use(
(response) => {
return response
},
(error) => {
return Promise.reject(error)
}
)
export default http
这里我们就可以得到一个axios实例http,axios-retry就可以对这个实例进行封装实现重新请求
import axios from 'axios'
import axiosRetry from 'axios-retry'
const api = axios.create({
headers: {},
timeout: 5 * 1000 // 请求超时时间
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
console.log(config)
return config
},
(err) => {
console.log(err)
return Promise.reject(err)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
// 将index变回0
index = 0
return response
},
(error) => {
return Promise.reject(error)
}
)
axiosRetry(api, {
retries: 5,
shouldResetTimeout: true,
retryDelay: (retryCount) => {
// retryCount为重试的次数
return retryCount * 1000
},
retryCondition: err => {
console.log(err)
index++
if (index === 5) {
// 超过五次进行提示就不进行请求
Toast('请求超时,请重新提交')
index = 0
return false
} else {
if (err.message.includes('timeout')) return true
return false
}
}
})
export default api
axiosRetry
对于axiosRetry来说,我们只需要去配置下面参数就行
- retries 重试次数
- retryCondition 重试条件,返回ture就允许重试,返回false就不允许重试
- shouldResetTimeout 是否重置超时,ture代表每次重试都重置超时时间 false则相反
- retryDelay 延迟重试时间,需要返回一个时间
- onRetry 每次重试时执行的回调函数
- onMaxRetryTimesExceeded 当达到最大重试次数后执行的回调函数
- validateResponse 用于验证响应是否有效的函数
可以看到,我代码中定义闭包了一个index变量,在retryCondition中去判断是否为等于5,如果等于5就不进行重试并提示给用户进行重新提交请求,这里要注意的是要维护好index这个变量,在请求成功后变回0。本来我是想在onMaxRetryTimesExceeded 这个配置项去写逻辑的,但是不知道为什么没有执行这里面的逻辑,我也没去研究了,能实现效果就行了。
源码
大家可以npm上这个位置去看源码,大家如果感兴趣,最好还是自己去看一下源码,我的分析可能比较片面,而且我本身技术也就那样。这个第三方包的源码相比vue的源码其实还算简单的,没有那么复杂。虽然简单,我看的也很头大。如果你要继续往下面看,就需要你对axios本身有一丢丢了解才行,就比如请求拦截器,响应拦截器这些。当然也能继续看,就是可能会有点迷迷糊糊的,但是肯定是能学到东西的!然后我是将代码的逻辑通过注释是写在代码里面了,所以要先看一下代码块里面的东西。
axiosRetry
这个方法就是这个第三方包的主函数,我先说下这个包整体上的实现逻辑,在请求拦截器和响应拦截器中维护一个对象,在响应拦截器中,通过这个对象中一些信息去判断要不要重新请求
。可以看到这个函数接收两个参数,一个是axiosInstance
axios实例,一个defaultOptions
也就是我在使用axios-retry
配置的配置项。可以看下面的代码,从大的方面来看就是一个请求拦截器,一个响应拦截器,最后将这两个拦截器给return了。
const axiosRetry = (axiosInstance, defaultOptions) => {
// 请求拦截器
const requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
setCurrentState(config, defaultOptions, true);
// 这一段代码可以向不看,这个是为了实现配置项上的validateResponse的功能
// =====1
if (config[namespace]?.validateResponse) {
config.validateStatus = () => false;
}
// =====1
return config;
});
// 响应拦截器
const responseInterceptorId = axiosInstance.interceptors.response.use(null, async (error) => {
const { config } = error;
// 如果没有config,无法判断是否需要重新请求,直接返回错误
if (!config) {
return Promise.reject(error);
}
const currentState = setCurrentState(config, defaultOptions);
// 这一段可以先不看,为了实现配置项上的validateResponse的功能
// =====2
if (error.response && currentState.validateResponse?.(error.response)) {
// 如果响应没问题(通过 validateResponse 验证)则直接返回响应
return error.response;
}
// =====2
// 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
if (await shouldRetry(currentState, error)) {
return handleRetry(axiosInstance, currentState, error, config);
}
// 这一段可以先不看,为了实现配置项上的 onMaxRetryTimesExceeded 的功能
// =====3
// 在达到最大重试次数后执行相应回调(调用 handleMaxRetryTimesExceeded 函数)
await handleMaxRetryTimesExceeded(currentState, error);
// =====3
return Promise.reject(error);
});
return { requestInterceptorId, responseInterceptorId };
};
除了===之间的内容不看后,拦截器里面剩下的就很简单了,在请求拦截器中,就是调用了setCurrentState这个方法,要想理解setCurrentState这个方法,我们得先知道对于一个axois,发起请求是有一个config对象,这个对象里面包括像请求头,请求方式等等的一些字段,所以这个我们可以理解成一个给config对象中添加属性的方法,源码如下。前面我们说过,这个包的整体思路就是在请求拦截器和响应拦截器维护一个对象
,而这个对象就是config中的某一个属性,也就是config[namespace]。namespace是一个变量,变量值为axios-retry,也就是config中叫axios-retry的属性。
setCurrentState
这个方法接收三个参数,一个是axois请求的配置,一个用户的配置,一个是否需要重置上次请求时间。可以看下面的代码,一开始是调用了getRequestOption
s的这个方法,这个方法就一个合并对象的方法,合并的对象就是我们前面所说的在请求拦截器和响应拦截器维护的那个对象。它是将,我们axios-retry默认配置
,用户的配置
以及config[namespace]
(也就是维护的那个对象)合并成一个对象。整体去看setCurrentState这个方法,可以分为1,2,3步,分别对应着拿变量,改变量,存变量
,就和维护变量的操作一模一样。
function setCurrentState(config, defaultOptions, resetLastRequestTime = false) {
// 合并配置参数 getRequestOptions方法在下面----------------------------1
const currentState = getRequestOptions(config, defaultOptions || {});
// 初始化或更新重试次数,retryCount就是记录当前重试的次数
// 如果currentState中没有这个变量,就是第一次请求,有就使用这个变量--------2
currentState.retryCount = currentState.retryCount || 0;
// 更新上次请求时间
if (!currentState.lastRequestTime || resetLastRequestTime) {
currentState.lastRequestTime = Date.now();
}
// 赋值给config配置项 namespace就是一个变量,在下面的代码,这就是维护变量的操作---------3
config[namespace] = currentState;
return currentState;
}
// 合并默认配置,就是将默认的,用户设置的,和config中的配置合并
function getRequestOptions(config, defaultOptions) {
return { ...DEFAULT_OPTIONS, ...defaultOptions, ...config[namespace] };
}
// 下面这些代码可以先不看
//===============================================================
// 定义添加config对象中的属性名
export const namespace = 'axios-retry';
// 默认配置对象 isNetworkOrIdempotentRequestError和noDelay是一个默认方法,
// 大家感兴趣可以去看源码,因为如果用户有配置的话,就是使用用户配置的回调函数
export const DEFAULT_OPTIONS = {
retries: 3,
retryCondition: isNetworkOrIdempotentRequestError,
retryDelay: noDelay,
shouldResetTimeout: false,
onRetry: () => { },
onMaxRetryTimesExceeded: () => { },
validateResponse: null
};
我们结合上面的axiosRetry来看,在请求拦截器和响应拦截器都使用了这个方法,也就是说在每一次请求的时候都去更新维护config[namespace]对象
。这也就是为啥一直在说核心就是请求拦截器和响应拦截器维护一个对象,那为什么要维护这个对象呢?别急,马上就来了!我们再回去看响应拦截器,除开1,2,3段可以先不看,就剩下下面两行代码,这段代码也就是这个包的核心代码,这段代码主要使用了shouldRetry
和handleRetry
两个方法,可以看到这两个方法都使用了currentState
这个变量,这个变量就是我们一直强调的那个'维护的对象'
。shouldRetry方法是用来判断要不要重新的请求,而handleRetry是用来重新请求的方法。
// 根据是否满足重试条件来决定是执行重试操作(调用 handleRetry 函数)
if (await shouldRetry(currentState, error)) {
return handleRetry(axiosInstance, currentState, error, config);
}
维护的对象
说这么多,这个'维护的对象'
到底是什么,我们在请求拦截器中打印一下config这个对象,可能大家已经忘了config是啥,config就是我们在请求拦截器回调接收的那个参数
,也就是axois发起请求的配置。在控制台可以看到其中会有axios-retry
这样的一个属性,也就是namespace变量的值。我们一直在维护的也是这个axios-retry的值。这个对象里面有重试次数,上次请求时间,重试条件,重试回调等等,也就是我们所配置的那些东西。也就是形参currentState需要的值。
shouldRetry
async function shouldRetry(currentState, error) {
// 从currentState拿到retries, retryCondition
const { retries, retryCondition } = currentState;
// 如果没超过重试次数,然后通过retryCondition去判断,根据这两个去判断要不要重新请求
const shouldRetryOrPromise = (currentState.retryCount || 0) < retries && retryCondition(error);
// 这一段代码是为了兼容retryCondition可能是promise的值,就要去等待他执行完成
// =========1
if (typeof shouldRetryOrPromise === 'object') { // 这可能是一个promise
try {
const shouldRetryPromiseResult = await shouldRetryOrPromise;
// 保持 return true,除非 shouldRetryPromiseResult 返回 false 以实现兼容性
return shouldRetryPromiseResult !== false;
}
catch (_err) {
return false;
}
}
// ========1
return shouldRetryOrPromise;
}
这个方法其实很简单,就是通过重试次数,以及用户配置的retryCondition回调,去得到一个布尔值
。整体逻辑大家应该都能看得懂,这里需要给大家讲一下error是什么,error就是在响应拦截器中请求失败的回调的传参,也就是当axios请求失败报错的那个值
。下面这张图可以看到这个error中也是有config
属性的,也有axios-retry
的,这很重要!
handleRetry
这个方法就是实现重试的方法,接收四个参数,分别是axiosInstance
axios实例,currentState
就是config中的axios-retry属性,也就是维护的那个对象,error
就是上面那个error,config就是那个config,之前都有提过。
async function handleRetry(axiosInstance, currentState, error, config) {
// 重试次数加1
currentState.retryCount += 1;
const { retryDelay, shouldResetTimeout, onRetry } = currentState;
// 执行retryDelay,也就是用户配置的那个retryDelay
const delay = retryDelay(currentState.retryCount, error);
// 修复config======可以不看,为了兼容,感兴趣的可以去细看源码
fixConfig(axiosInstance, config);
// 这一段代码是为实现用户配置shouldResetTimeout是否重置超时时间的功能
// 如果是false,也就是不进行重置超时时间,所以这里要去更新config中的timeout。
// 如果是ture就不进入这个if,不对timeout做处理
if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) {
const lastRequestDuration = Date.now() - currentState.lastRequestTime;
const timeout = config.timeout - lastRequestDuration - delay;
if (timeout <= 0) {
return Promise.reject(error);
}
config.timeout = timeout;
}
// config.transformRequest是对请求数据进行处理,这里的意思就是传入了什么,就用什么数据。
// 这行代码是为了重置转换函数。
config.transformRequest = [(data) => data];
// 执行onRetry,也就是用户配置的onRetry
await onRetry(currentState.retryCount, error, config);
// config.signal是AbortController产生的,AbortController是提供取消异步操作的一个js接口。
// 这里所有关于config.signal都是为了兼容,兼容用户对请求进行主动取消的情况下。
// 他是去监听abort事件,因为如果用户需要主动取消请求,会去触发abort事件
// 这里是做了一个防抖以及监听事件和取消监听。
// 如果没有接触过,可以直接把这些相关代码(1,2,3,4)先删了再去看,
// 把这些删了之后发现就只剩下了一个定时器和axiosInstance(config)。
// axiosInstance(config)就是重新请求。
// =============1
if (config.signal?.aborted) {
return Promise.resolve(axiosInstance(config));
}
// =============1
return new Promise((resolve) => {
// =============2
const abortListener = () => {
clearTimeout(timeout);
resolve(axiosInstance(config));
};
// =============2
// delay是上面retryDelay得出的东西
const timeout = setTimeout(() => {
resolve(axiosInstance(config));
// =============3
if (config.signal?.removeEventListener) {
config.signal.removeEventListener('abort', abortListener);
}
// =============3
}, delay);
// =============4
if (config.signal?.addEventListener) {
config.signal.addEventListener('abort', abortListener, { once: true });
}
// =============4
});
}
梳理
看到这里大家可能明白了,可能很懵。因为我这是对核心源码一行一行的去注释,可能并不能将整条线连起来,所以我这用文字去总结一下。首先
,我们先将重试作为主线
,去看重试是怎么实现的。还是那个'维护的对象'
,这个对象串联了整条线,这个对象包括我们的重试条件,重试回调等等这些方法。我们先在请求拦截器中和响应拦截器中都是使用了setCurrentState
去维护这个对象,然后再响应拦截器中去通过shouldRetry
去判断该不该重试,再通过handleRetry
去重试。而这两个方法实现的前提就是这个'维护的对象'
。比如该不该重试,是通过用户配置的retryCondition和重试次数去判断的,再比如怎么去重试,是通过axios实例配合config参数再次请求。其次
,我们再通过我们配置的参数,去看重试这条主线的支线
,也就是retryDelay
重试延时时间,shouldResetTimeout
是否重置超时时间,onRetry
重试回调,onMaxRetryTimesExceeded
最大重试次数后执行的回调,validateResponse
验证响应内容。这些在上面的代码注释中,我都有标明在哪里实现的。
总结
axios-retry这个包是很不错的,可以在无响应报错的时候进行重新请求。在前后端交互的时候,或多或少都会遇到接口不响应超时的问题。而在一些很需要的接口响应的场景,是很实用的,然后源码看不看懂其实都无所谓,会用就行,而且我们这种底层前端的工作内容基本都是curd,根本不用去造轮子。不过看源码也是有很多好处的,比如怎么封装包能让用户有更多的扩展性,像这里的retryCondition和onRetry就不错。还有可以增加自己的自信心的,这包一周是有三百万人在使用的,感觉也没有多复杂,我又觉得我行了(手动狗头)。最后,来个赋能哥带我上分呗,我真打不上去啊。
来源:juejin.cn/post/7439654496694255670
大屏可视化效果实现记录
适配及响应式处理
效果实现
Echarts线图线条渐变色及区域渐变
- 效果图

- 关注点
- 线条颜色渐变
- 线条含有阴影
- 区域填充色渐变
- 配置项
series:[{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: false,
lineStyle: {
normal: {
// 1. 设置线条渐变色
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FDFDFF',
},
{
offset: 0.3,
color: '#6EA4F8',
},
{
offset: 0.6,
color: '#7DA0E0',
}, {
offset: 1,
color: '#679BF0',
},
]),
width: 3,
// 2. 设置线条阴影
shadowColor: '#2E4F84',
shadowOffsetY: 15,
shadowOffsetX: 5,
shadowBlur: 3,
},
},
// 3. 设置区域填充渐变:渐变色设置文档 https://echarts.apache.org/zh/option.html#color
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(36,173,254, 0.5)',
}, {
offset: 1,
color: 'rgba(52,112,252, 0.1)',
},
],
},
},
}]
- 效果图
- 关注点
- 线条颜色渐变
- 线条含有阴影
- 区域填充色渐变
- 配置项
series:[{
data: [820, 932, 901, 934, 1290, 1330, 1320],
type: 'line',
smooth: false,
lineStyle: {
normal: {
// 1. 设置线条渐变色
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: '#FDFDFF',
},
{
offset: 0.3,
color: '#6EA4F8',
},
{
offset: 0.6,
color: '#7DA0E0',
}, {
offset: 1,
color: '#679BF0',
},
]),
width: 3,
// 2. 设置线条阴影
shadowColor: '#2E4F84',
shadowOffsetY: 15,
shadowOffsetX: 5,
shadowBlur: 3,
},
},
// 3. 设置区域填充渐变:渐变色设置文档 https://echarts.apache.org/zh/option.html#color
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(36,173,254, 0.5)',
}, {
offset: 1,
color: 'rgba(52,112,252, 0.1)',
},
],
},
},
}]
Echarts外环饼图
- 效果图

- 关注点
- 内圈含有间隔数据
- 外圈效果
- 配置项
// 数据处理
// 间隔空白数据
const gapData = {
name: '',
value: 20,
itemStyle: {
color: 'transparent', // 颜色设置为透明数据
},
};
// 计算饼图渲染数据
const seriesData = [];
[
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
].forEach((item) => {
seriesData.push(item);
seriesData.push(gapData);
});
// 图表配置项
series: [
// 内圆环配置项
{
data: seriesData,
roundCap: true,
center: ['50%', '50%'],
radius: ['50%', '60%'],
label: {
show: false,
position: 'center',
},
},
// 外圆环配置项
{
type: 'pie',
name: '旋转圆',
silent: true,
center: ['50%', '50%'],
radius: ['70%', '69%'],
hoverAnimation: false,
startAngle: 50,
// Notes:这里的数据根据要展示的外环段数及长短自定义设置
data:[120, 40, 120, 40, 120, 40].map((item, index) => ({
value: item,
name: '',
itemStyle: {
color: index % 2 === 0 ? '#5999E1' : 'transparent',
shadowBlur: 20,
shadowColor: '#86C6FD',
},
})),
label: {
normal: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
}
],
- 效果图
- 关注点
- 内圈含有间隔数据
- 外圈效果
- 配置项
// 数据处理
// 间隔空白数据
const gapData = {
name: '',
value: 20,
itemStyle: {
color: 'transparent', // 颜色设置为透明数据
},
};
// 计算饼图渲染数据
const seriesData = [];
[
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
].forEach((item) => {
seriesData.push(item);
seriesData.push(gapData);
});
// 图表配置项
series: [
// 内圆环配置项
{
data: seriesData,
roundCap: true,
center: ['50%', '50%'],
radius: ['50%', '60%'],
label: {
show: false,
position: 'center',
},
},
// 外圆环配置项
{
type: 'pie',
name: '旋转圆',
silent: true,
center: ['50%', '50%'],
radius: ['70%', '69%'],
hoverAnimation: false,
startAngle: 50,
// Notes:这里的数据根据要展示的外环段数及长短自定义设置
data:[120, 40, 120, 40, 120, 40].map((item, index) => ({
value: item,
name: '',
itemStyle: {
color: index % 2 === 0 ? '#5999E1' : 'transparent',
shadowBlur: 20,
shadowColor: '#86C6FD',
},
})),
label: {
normal: {
show: false,
},
},
labelLine: {
normal: {
show: false,
},
},
}
],
Echarts 渐变色柱状图
- 效果图

- 关注点
- 柱体颜色渐变
- 配置项
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
// 设置柱体颜色渐变
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: '#20517E',
opacity: 0.85,
},
{
offset: 0,
color: '#3FC0F7',
opacity: 0.79,
},
]),
},
},
label: {
show: true,
color:'#3FC0F7',
fontSize: 12,
position: 'outside',
},
}
]
- 效果图
- 关注点
- 柱体颜色渐变
- 配置项
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
// 设置柱体颜色渐变
itemStyle: {
normal: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: '#20517E',
opacity: 0.85,
},
{
offset: 0,
color: '#3FC0F7',
opacity: 0.79,
},
]),
},
},
label: {
show: true,
color:'#3FC0F7',
fontSize: 12,
position: 'outside',
},
}
]
Echarts含图片标签渐变色柱状图
- 效果图

- 关注项
- 渐变色柱体
- 高亮结尾
- 数据标签含背景图
- 配置项
option = {
backgroundColor:'#17243A',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: [
{
inverse: true,
axisLabel: {
color: '#ADCBE9',
fontSize: 20,
formatter: (value) => {
if (value.length < 8) {
return value;
}
return `${value.substring(0, 8)}...`;
},
},
axisLine: {
lineStyle: {
color: 'transparent',
},
},
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}, {
inverse: true,
axisTick: 'none',
axisLine: 'none',
axisLabel: {
show: true,
fontSize: 20,
fontWeight: 'bold',
color: '#BFD1E3',
padding: [5, 12, 5, 12],
backgroundColor: {
image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAWCAYAAAA/45nkAAAACXBIWXMAAAsTAAALEwEAmpwYAAABAElEQVRoge3ZwQoBURTG8XOvKzGzmnQ3SpKJrMZb2HsQL+MRLCkb5RHsbNVspGwkRYamplipaSiUOodzZnl/d/FvZvPVqP5geoXMs18ve0l8ibLnfq1SDlebXfZc7LWZQtHxqq1x1g0AwDZcdKmG/4sl8SW6v+e7HfJ2qCnEcTaNHcDdNAAA1TgOlqv77TnVuH+3ODpO9LuXxb5vSXw+PXwAKnFcTH9yWez7JjMU2WSGYs9Q7ADuprEDuJvMUGSTGYpsMkORTWYooplCydW2EYwoxnEwr9ocaVDKpRjHxZ6uICpxHExmKLIZAADrd2bpwzD175Jq+C+abQRjUMpJ+w1co8ZyUwNCuQAAAABJRU5ErkJggg==',
},
},
data: [120, 200, 150, 80, 70, 110, 130],
},
],
series: [
{
type: 'pictorialBar',
symbol: 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA1CAYAAAAOJMhOAAAACXBIWXMAAAsTAAALEwEAmpwYAAAI9ElEQVRogbWa244cxw2GP1b37EGSAztAAsNArvIYAfIEeetc5hlyZSlxACuCvV7NaqebzEUVq1mcXmlWUgoYzEx3VTV/Hn6SNSM//WZcMixME7l8LiAiWL7ve3zOvh+bVz6+xbOH5M8NjOzcj/M+AeXyMX+FPQSqBZ7QoN+XZhm/a3FtWNOvfc74LAsll/JvAlgTTNJ9aWuy8Ij0724p4wss9jVcrmvcBQuAB8FC3Fh698++8sxCdqHNLgV0FhvxWWza7W6VQSXLSXj1eWnNZ1lp3kP+BItktjqbFdjLYky063tM1+NIBMzq58iCzx2XkkKMDdsJfqMCduHJQoU1siPsYJ0dxV1MEp9yuWj6YeMdJsMMcxbbIYeSXBEqEKGCyEy35+aftNunAHmg7vl+37wJFSm5x1MTVoJL5fsZ3J587gF7ihrGs/KQu1FwLQmudaac5KaSLNDnNJbD54XrTvM5l9lTrLcHKOeCIWlGYOF7FGIQvAnVLZrWRjfuROLg9hSQ9jyDNecADZPjBl2TaW53sxgLRWDVem8qiFq9pnV+AdQV0B+SPgfgmpTgQLs08f6ZhWLyCxuXdq/7cnxPxaawMZloe6yOysixYcmVonVKuy4iT1YifWRAna1g8NtOy2meB6oHeZlKFb4Ik27ljriFzGAqsCpriEENSooCRlbN+eksFDKgqOlshQ7SQbiLBW0VqUKXUi1aApju71O1jTUwJoK2ueq0H+o+T7oxXmKM52sdUMwdBIE7y6SA70m0xcvUrhWrj3JA0sCJGlbq2rWix3SLD20KMUZ3i3Gym5CDRQWwaCH33+irmG2Wi/4bCGESoTQrFIQpgmmAaGBMjYlKCuqKCRr26y6PK7oTQSCoDFyihfqFXETmmqyN4rlHrbvU1L7PpYEqQimjWxrSBV/VWIuwNAWsLeY0PEPZRs5P7rJDeokud+arkcJDQnX3K7K5llvl0Kwyz6PbOdtZqVYyZbOeGlqq8EKKpwCoM2FIFR2IY+jVdlocu8shzxCIgS1GHNRchGne3O6qyKC0tQhrEU5LFd9z1EKNLTHrlL4G5VmKH4/tzIgyO7IopMdS0MIAuGlcGgGIGmUuzFQSm4twUONQhCtqjE1TpV0FlnbvqMbKVi/OwBJipYhU0oAhHLJV4jWbYzLLdVQEGR7iYMpUeqxMDUwBZjWuinAD3EyF6wawlMKqxoMYRzOY4bg0MmixOKtxSqmhWyK2LamF6TTeLeTv0c32WMQ108B0MmjAihozcCPCTRG+mQrfALdTdb3TJNwX45dFgWohbXtMwGMsdkVYvUAlULSnlFQCbaSww+9DQIaKAFoMNbYqbCwmakxFuC7CXIQXRfh2gj8A3xXhAHw4GT9TY+9kwsMMp8UqwxWpINiIZHUwMY726kqfN5Q+sVLYATLM8YKTjZm6tUQ4FOF2Fr6bCn8R4W9NqL8f4B8nWArcrVRXjHs0krEGRryYTZV4bymSzFZSdR2THL6AUI8lnwUqSaRmaAKugVcNzAvgVoS/ivD7CV6KcCPVkuJ7NCtbKmTPqurkaoN/FTZWMy89fHKi8r7Y5+vWYapuG8ecIg2Mj5sGdo7PafNMz1uX8eGBKOK1+LXAUJIPlB3vmdUEOGhjK2esgfIKQIEPKxx1WAFm/Ar8tiqPbW6k7ggSRuvk06OBpHxaZLkK+NxX43tkQgAcDGCzsC7GMsHDotwfJt7enyqfT6WaBvj3Cm+BoxmPWil7gVq8thYjKrK7f4rlAbjPnxvivnCP8ULRGmPMqO4lLbBNa7ZfV+WhCHerUn4+wu1c970qcHvgR+DtqvyyKA8OBrBV0dYbdWuFSqHnoh2ichbsFhqKvmSR2I2qtw0twWqr3dRZqQiPi6EzgHJ6cw83U31NAn/6Hf8y416N9woL7aVbTrKmxJ5notfEUii6oM/x4Iws102aKu8csGrGpNVKPs+871mMYzHW13dwmODlXN3uh1e8U+O0VMucaHFkxppqs+gdltnNQyJ1sWPHKuPRrDOakWIraGVpoISt7JceV4K+voPrubrbzQzLH2sNp8ZSpBOItRKsM62kZm/PGmn00ufsnDkh722Fm9ldLrTQtELVNeztt/3nWMFcTfWlVq0SwCg19rTtpbaB7BbJZBWuxZgms1yOIV+QN7EmgAuuQGkHH9ZqOrsqTO8XsKlO+rDCVaEsxmlRTm6RFpvWwJwl0iBbVKTtgOsCdc2nGikCHfJEqCA8kNemYVNDrwpyPXML8OpQme6kcD1zW1rQB8VEMNaauCHRN9ksAJbw6rL7eVvWRKwWBo3F+21z7y7VKuutRdC5ILPw4vU9HNet9puFF4eJMhVMamPXXc7BBMHjc6J3nMWZv4biNJcRyVIS3mOPIrKVOn3+VFBg/vE9fH8LDwu8uQdgKtKLTw3Cejz6wWPcP8sVC4Eh9iX8rN8FzsyWRi+RQqLt1XlbJy8PvLiZ+H4q/Bn4oa19syr/fFR+uj9xbIk0ap34HuNpp/eJwPoYjrEiqIR8+AU7MmDIE91yrXL+sMJ/izGJ8Gub+26Fd6vy2MD0g48IIlUHww/OIbn2FuIpQIOAAUTW0Mdcz6DWd8eFUxHuKDxOxluAFR5PK8eHlZNbJSomJdZoHbfg2YluHnu/sQ7ny/6g0OfHh8XWQ+MZhBp6XHgscJoL7327R0V11P6QQOPe7fmdrgPwJ8fe70Pd/HFhqp3OzJ/nAyxa531YWcP67UG2daJZ2FTHuUJzGfbRGNobcYP47sHvD41VeNZk13QCcyZMcrl+UivSma9f36n7LgIUk+heqRFPUjvlprn5ODczF2Gv4dlPpJFdID6e80+S7vO5qw0Pj/Qb/T+2JxEwYb/hVCe5ouW9nxoX/WicNRk/Jy0ONOtWcheJ39kUEVuW3SIZhs71ywE5Ls61lE9X+/WUCNm5F10sMlmPwbjmUiGfA2jP97eb4Y9IqVIfrLpXQae5BJe+GIiPZ/8bKwkrMXZiXBC0HPsozl3yqfFsMPBlfwA8e2Bwk6EhhA6qX/9/ja/xj8Y4csIbvst4cJmr+c+ySB7/A5p05mnftr1GAAAAAElFTkSuQmCC',
symbolOffset: [20, -5],
symbolSize: [40, 40],
symbolPosition: 'end',
z: 12,
data: [120, 200, 150, 80, 70, 110, 130],
}, {
name: '',
type: 'bar',
showBackground: true,
yAxisIndex: 0,
barWidth: 7,
barBorderRadius: 10,
data: [120, 200, 150, 80, 70, 110, 130].map((value, index) => ({
value,
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: '#2F3E56',
},
{
offset: 1,
color:'#7BB1EE',
},
],
},
},
},
})),
},
]
};
- 效果图
- 关注项
- 渐变色柱体
- 高亮结尾
- 数据标签含背景图
- 配置项
option = {
backgroundColor:'#17243A',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01]
},
yAxis: [
{
inverse: true,
axisLabel: {
color: '#ADCBE9',
fontSize: 20,
formatter: (value) => {
if (value.length < 8) {
return value;
}
return `${value.substring(0, 8)}...`;
},
},
axisLine: {
lineStyle: {
color: 'transparent',
},
},
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}, {
inverse: true,
axisTick: 'none',
axisLine: 'none',
axisLabel: {
show: true,
fontSize: 20,
fontWeight: 'bold',
color: '#BFD1E3',
padding: [5, 12, 5, 12],
backgroundColor: {
image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAWCAYAAAA/45nkAAAACXBIWXMAAAsTAAALEwEAmpwYAAABAElEQVRoge3ZwQoBURTG8XOvKzGzmnQ3SpKJrMZb2HsQL+MRLCkb5RHsbNVspGwkRYamplipaSiUOodzZnl/d/FvZvPVqP5geoXMs18ve0l8ibLnfq1SDlebXfZc7LWZQtHxqq1x1g0AwDZcdKmG/4sl8SW6v+e7HfJ2qCnEcTaNHcDdNAAA1TgOlqv77TnVuH+3ODpO9LuXxb5vSXw+PXwAKnFcTH9yWez7JjMU2WSGYs9Q7ADuprEDuJvMUGSTGYpsMkORTWYooplCydW2EYwoxnEwr9ocaVDKpRjHxZ6uICpxHExmKLIZAADrd2bpwzD175Jq+C+abQRjUMpJ+w1co8ZyUwNCuQAAAABJRU5ErkJggg==',
},
},
data: [120, 200, 150, 80, 70, 110, 130],
},
],
series: [
{
type: 'pictorialBar',
symbol: 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA1CAYAAAAOJMhOAAAACXBIWXMAAAsTAAALEwEAmpwYAAAI9ElEQVRogbWa244cxw2GP1b37EGSAztAAsNArvIYAfIEeetc5hlyZSlxACuCvV7NaqebzEUVq1mcXmlWUgoYzEx3VTV/Hn6SNSM//WZcMixME7l8LiAiWL7ve3zOvh+bVz6+xbOH5M8NjOzcj/M+AeXyMX+FPQSqBZ7QoN+XZhm/a3FtWNOvfc74LAsll/JvAlgTTNJ9aWuy8Ij0724p4wss9jVcrmvcBQuAB8FC3Fh698++8sxCdqHNLgV0FhvxWWza7W6VQSXLSXj1eWnNZ1lp3kP+BItktjqbFdjLYky063tM1+NIBMzq58iCzx2XkkKMDdsJfqMCduHJQoU1siPsYJ0dxV1MEp9yuWj6YeMdJsMMcxbbIYeSXBEqEKGCyEy35+aftNunAHmg7vl+37wJFSm5x1MTVoJL5fsZ3J587gF7ihrGs/KQu1FwLQmudaac5KaSLNDnNJbD54XrTvM5l9lTrLcHKOeCIWlGYOF7FGIQvAnVLZrWRjfuROLg9hSQ9jyDNecADZPjBl2TaW53sxgLRWDVem8qiFq9pnV+AdQV0B+SPgfgmpTgQLs08f6ZhWLyCxuXdq/7cnxPxaawMZloe6yOysixYcmVonVKuy4iT1YifWRAna1g8NtOy2meB6oHeZlKFb4Ik27ljriFzGAqsCpriEENSooCRlbN+eksFDKgqOlshQ7SQbiLBW0VqUKXUi1aApju71O1jTUwJoK2ueq0H+o+T7oxXmKM52sdUMwdBIE7y6SA70m0xcvUrhWrj3JA0sCJGlbq2rWix3SLD20KMUZ3i3Gym5CDRQWwaCH33+irmG2Wi/4bCGESoTQrFIQpgmmAaGBMjYlKCuqKCRr26y6PK7oTQSCoDFyihfqFXETmmqyN4rlHrbvU1L7PpYEqQimjWxrSBV/VWIuwNAWsLeY0PEPZRs5P7rJDeokud+arkcJDQnX3K7K5llvl0Kwyz6PbOdtZqVYyZbOeGlqq8EKKpwCoM2FIFR2IY+jVdlocu8shzxCIgS1GHNRchGne3O6qyKC0tQhrEU5LFd9z1EKNLTHrlL4G5VmKH4/tzIgyO7IopMdS0MIAuGlcGgGIGmUuzFQSm4twUONQhCtqjE1TpV0FlnbvqMbKVi/OwBJipYhU0oAhHLJV4jWbYzLLdVQEGR7iYMpUeqxMDUwBZjWuinAD3EyF6wawlMKqxoMYRzOY4bg0MmixOKtxSqmhWyK2LamF6TTeLeTv0c32WMQ108B0MmjAihozcCPCTRG+mQrfALdTdb3TJNwX45dFgWohbXtMwGMsdkVYvUAlULSnlFQCbaSww+9DQIaKAFoMNbYqbCwmakxFuC7CXIQXRfh2gj8A3xXhAHw4GT9TY+9kwsMMp8UqwxWpINiIZHUwMY726kqfN5Q+sVLYATLM8YKTjZm6tUQ4FOF2Fr6bCn8R4W9NqL8f4B8nWArcrVRXjHs0krEGRryYTZV4bymSzFZSdR2THL6AUI8lnwUqSaRmaAKugVcNzAvgVoS/ivD7CV6KcCPVkuJ7NCtbKmTPqurkaoN/FTZWMy89fHKi8r7Y5+vWYapuG8ecIg2Mj5sGdo7PafNMz1uX8eGBKOK1+LXAUJIPlB3vmdUEOGhjK2esgfIKQIEPKxx1WAFm/Ar8tiqPbW6k7ggSRuvk06OBpHxaZLkK+NxX43tkQgAcDGCzsC7GMsHDotwfJt7enyqfT6WaBvj3Cm+BoxmPWil7gVq8thYjKrK7f4rlAbjPnxvivnCP8ULRGmPMqO4lLbBNa7ZfV+WhCHerUn4+wu1c970qcHvgR+DtqvyyKA8OBrBV0dYbdWuFSqHnoh2ichbsFhqKvmSR2I2qtw0twWqr3dRZqQiPi6EzgHJ6cw83U31NAn/6Hf8y416N9woL7aVbTrKmxJ5notfEUii6oM/x4Iws102aKu8csGrGpNVKPs+871mMYzHW13dwmODlXN3uh1e8U+O0VMucaHFkxppqs+gdltnNQyJ1sWPHKuPRrDOakWIraGVpoISt7JceV4K+voPrubrbzQzLH2sNp8ZSpBOItRKsM62kZm/PGmn00ufsnDkh722Fm9ldLrTQtELVNeztt/3nWMFcTfWlVq0SwCg19rTtpbaB7BbJZBWuxZgms1yOIV+QN7EmgAuuQGkHH9ZqOrsqTO8XsKlO+rDCVaEsxmlRTm6RFpvWwJwl0iBbVKTtgOsCdc2nGikCHfJEqCA8kNemYVNDrwpyPXML8OpQme6kcD1zW1rQB8VEMNaauCHRN9ksAJbw6rL7eVvWRKwWBo3F+21z7y7VKuutRdC5ILPw4vU9HNet9puFF4eJMhVMamPXXc7BBMHjc6J3nMWZv4biNJcRyVIS3mOPIrKVOn3+VFBg/vE9fH8LDwu8uQdgKtKLTw3Cejz6wWPcP8sVC4Eh9iX8rN8FzsyWRi+RQqLt1XlbJy8PvLiZ+H4q/Bn4oa19syr/fFR+uj9xbIk0ap34HuNpp/eJwPoYjrEiqIR8+AU7MmDIE91yrXL+sMJ/izGJ8Gub+26Fd6vy2MD0g48IIlUHww/OIbn2FuIpQIOAAUTW0Mdcz6DWd8eFUxHuKDxOxluAFR5PK8eHlZNbJSomJdZoHbfg2YluHnu/sQ7ny/6g0OfHh8XWQ+MZhBp6XHgscJoL7327R0V11P6QQOPe7fmdrgPwJ8fe70Pd/HFhqp3OzJ/nAyxa531YWcP67UG2daJZ2FTHuUJzGfbRGNobcYP47sHvD41VeNZk13QCcyZMcrl+UivSma9f36n7LgIUk+heqRFPUjvlprn5ODczF2Gv4dlPpJFdID6e80+S7vO5qw0Pj/Qb/T+2JxEwYb/hVCe5ouW9nxoX/WicNRk/Jy0ONOtWcheJ39kUEVuW3SIZhs71ywE5Ls61lE9X+/WUCNm5F10sMlmPwbjmUiGfA2jP97eb4Y9IqVIfrLpXQae5BJe+GIiPZ/8bKwkrMXZiXBC0HPsozl3yqfFsMPBlfwA8e2Bwk6EhhA6qX/9/ja/xj8Y4csIbvst4cJmr+c+ySB7/A5p05mnftr1GAAAAAElFTkSuQmCC',
symbolOffset: [20, -5],
symbolSize: [40, 40],
symbolPosition: 'end',
z: 12,
data: [120, 200, 150, 80, 70, 110, 130],
}, {
name: '',
type: 'bar',
showBackground: true,
yAxisIndex: 0,
barWidth: 7,
barBorderRadius: 10,
data: [120, 200, 150, 80, 70, 110, 130].map((value, index) => ({
value,
itemStyle: {
normal: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: '#2F3E56',
},
{
offset: 1,
color:'#7BB1EE',
},
],
},
},
},
})),
},
]
};
Echarts立体柱状图
- 效果图

- 关注点
- 三面立体
- 柱体渐变
- 配置项
// 自定义图形
// 绘制左侧面
export const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - offsetX, shape.y - offsetY];
const c2 = [xAxisPoint[0] - offsetX, xAxisPoint[1] - offsetY];
const c3 = [xAxisPoint[0], xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1])
.closePath();
},
});
// 绘制右侧面
export const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [xAxisPoint[0], xAxisPoint[1]];
const c3 = [xAxisPoint[0] + offsetX, xAxisPoint[1] - offsetY];
const c4 = [shape.x + offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
// 绘制顶面
export const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const c1 = [shape.x, shape.y];
const c2 = [shape.x + offsetX, shape.y - offsetY]; // 右点
const c3 = [shape.x, shape.y - offsetX];
const c4 = [shape.x - offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
function getRenderItem(param, type) {
const colorList = ['#66C9F2', '#80D1CD', '#9BD977'];
const color = colorList[param.dataIndex % 3];
const rgba = color16ToRGBA(color, type === 'top' ? 0.6 : 0.01);
return {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: rgba,
},
{
offset: 0,
color,
},
]),
};
}
// 图表配置项
config = {
xAxis: {
axisLine: {
lineStyle: {
color: 'transparent',
},
},
axisLabel: {
color: '#B1CBD8',
fontSize: 20,
},
},
yAxis: {
show: false,
splitLine: {
show: false,
},
},
series:[
{
type: 'custom',
// 使用自定义的图形进行绘制
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft', // 绘制左侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeRight', // 绘制右侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeTop', // 绘制顶层
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params, 'top'),
},
},
],
};
},
data: [120, 200, 150, 80, 70, 110, 130],
},
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
formatter: e => `${e.value}%`,
fontSize: 15,
color: '#fff',
offset: [0, -15],
},
},
itemStyle: {
color: 'transparent',
},
tooltip: {},
data: [120, 200, 150, 80, 70, 110, 130],
},
]
}
- 效果图
- 关注点
- 三面立体
- 柱体渐变
- 配置项
// 自定义图形
// 绘制左侧面
export const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - offsetX, shape.y - offsetY];
const c2 = [xAxisPoint[0] - offsetX, xAxisPoint[1] - offsetY];
const c3 = [xAxisPoint[0], xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1])
.closePath();
},
});
// 绘制右侧面
export const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [xAxisPoint[0], xAxisPoint[1]];
const c3 = [xAxisPoint[0] + offsetX, xAxisPoint[1] - offsetY];
const c4 = [shape.x + offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
// 绘制顶面
export const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath (ctx, shape) {
const c1 = [shape.x, shape.y];
const c2 = [shape.x + offsetX, shape.y - offsetY]; // 右点
const c3 = [shape.x, shape.y - offsetX];
const c4 = [shape.x - offsetX, shape.y - offsetY];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1])
.closePath();
},
});
function getRenderItem(param, type) {
const colorList = ['#66C9F2', '#80D1CD', '#9BD977'];
const color = colorList[param.dataIndex % 3];
const rgba = color16ToRGBA(color, type === 'top' ? 0.6 : 0.01);
return {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 1,
color: rgba,
},
{
offset: 0,
color,
},
]),
};
}
// 图表配置项
config = {
xAxis: {
axisLine: {
lineStyle: {
color: 'transparent',
},
},
axisLabel: {
color: '#B1CBD8',
fontSize: 20,
},
},
yAxis: {
show: false,
splitLine: {
show: false,
},
},
series:[
{
type: 'custom',
// 使用自定义的图形进行绘制
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
return {
type: 'group',
children: [
{
type: 'CubeLeft', // 绘制左侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeRight', // 绘制右侧面
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params),
},
},
{
type: 'CubeTop', // 绘制顶层
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: api.coord([api.value(0), 0]),
},
style: {
...getRenderItem(params, 'top'),
},
},
],
};
},
data: [120, 200, 150, 80, 70, 110, 130],
},
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
formatter: e => `${e.value}%`,
fontSize: 15,
color: '#fff',
offset: [0, -15],
},
},
itemStyle: {
color: 'transparent',
},
tooltip: {},
data: [120, 200, 150, 80, 70, 110, 130],
},
]
}
CSS旋转圆动画效果
- 效果图

- 关注点
- 背景图渐变
- 旋转动画
- 实现
<div class="value">
<span>{{ item.value }}span>
<span class="unit">%span>
div>
/** 定义旋转动画 **/
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.value {
width: 9vh;
height: 9vh;
line-height: 9vh;
text-align: center;
position: relative;
margin: auto;
border-radius: 50%;
/** 设置元素背景径向渐变色 **/
background: radial-gradient(50% 50% at 50% 50%, rgba(12, 27, 48, 0.1) 0%, rgba(12, 27, 48, 0.1) 49%, rgba(116, 217, 229, 0.1) 98%);
text-align: center;
.unit {
font-size: 1.4vh;
position: absolute;
margin-top: 3px;
margin-left: 3px;
}
/** 添加外环元素 **/
&::before,
&::after {
content: "";
position: absolute;
top: -1.5vh;
left: -1.5vh;
bottom: -1.5vh;
right: -1.5vh;
border-radius: 50%;
border-top: 3px solid #58A7B4;
/** 为外环元素添加旋转动画 **/
animation: rotate 6s infinite linear;
}
/** 第二个半圆添加动画延迟3S,使两个动画可以交替执行 **/
&::after {
animation-delay: 3s;
}
}
- 效果图
- 关注点
- 背景图渐变
- 旋转动画
- 实现
<div class="value">
<span>{{ item.value }}span>
<span class="unit">%span>
div>
/** 定义旋转动画 **/
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
.value {
width: 9vh;
height: 9vh;
line-height: 9vh;
text-align: center;
position: relative;
margin: auto;
border-radius: 50%;
/** 设置元素背景径向渐变色 **/
background: radial-gradient(50% 50% at 50% 50%, rgba(12, 27, 48, 0.1) 0%, rgba(12, 27, 48, 0.1) 49%, rgba(116, 217, 229, 0.1) 98%);
text-align: center;
.unit {
font-size: 1.4vh;
position: absolute;
margin-top: 3px;
margin-left: 3px;
}
/** 添加外环元素 **/
&::before,
&::after {
content: "";
position: absolute;
top: -1.5vh;
left: -1.5vh;
bottom: -1.5vh;
right: -1.5vh;
border-radius: 50%;
border-top: 3px solid #58A7B4;
/** 为外环元素添加旋转动画 **/
animation: rotate 6s infinite linear;
}
/** 第二个半圆添加动画延迟3S,使两个动画可以交替执行 **/
&::after {
animation-delay: 3s;
}
}
CSS元素浮动漂浮效果
- 效果图

- 实现
/** 定义浮动动画 **/
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
100% {
transform: translateY(0);
}
}
/** 为元素整体添加动画 **/
.indicator{
...其他样式项
animation: float 3s infinite ease-in-out;
/** 往后每个元素的动画执行延迟2s,保证不同的漂浮幅度 **/
&.indicator2{
animation-delay: 2s;
}
&.indicator3{
animation-delay: 4s;
}
&.indicator4{
animation-delay: 6s;
}
}
- 效果图
- 实现
/** 定义浮动动画 **/
@keyframes float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
100% {
transform: translateY(0);
}
}
/** 为元素整体添加动画 **/
.indicator{
...其他样式项
animation: float 3s infinite ease-in-out;
/** 往后每个元素的动画执行延迟2s,保证不同的漂浮幅度 **/
&.indicator2{
animation-delay: 2s;
}
&.indicator3{
animation-delay: 4s;
}
&.indicator4{
animation-delay: 6s;
}
}
字体渐变色
- 效果

- 关注点
- 背景绘制区域:background-clip
- 实现
span {
/** 设置字体的背景色为径向渐变色 **/
background: linear-gradient(180deg, #F5F5F5 0%, #7EB8E6 100%);
/** 将背景作用区域更新为文本,背景被裁剪为文字的形状 **/
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/** 文字本身设置为透明色 **/
color: transparent;
}
- 效果
- 关注点
- 背景绘制区域:background-clip
- 实现
span {
/** 设置字体的背景色为径向渐变色 **/
background: linear-gradient(180deg, #F5F5F5 0%, #7EB8E6 100%);
/** 将背景作用区域更新为文本,背景被裁剪为文字的形状 **/
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
/** 文字本身设置为透明色 **/
color: transparent;
}
其他
动态渲染大屏模块
- 背景
一般大屏页面会显示多个小模块,在不同的场景下分别配置显示哪几个。当前由接口提供一组要渲染的模块值,需要根据接口动态设置要渲染的内容
- 实现方式
由Vue component动态组件进行渲染
- 从接口获取一组模块名称:comList
- 对comList进行遍历,使用component :is 进行匹配渲染
- 注意组件的name名称,使用:is匹配时,需要字段值于之一致
<template v-for="name in comList">
<component :is="name" :key="name"/>
template>
- 背景
一般大屏页面会显示多个小模块,在不同的场景下分别配置显示哪几个。当前由接口提供一组要渲染的模块值,需要根据接口动态设置要渲染的内容
- 实现方式
由Vue component动态组件进行渲染
- 从接口获取一组模块名称:comList
- 对comList进行遍历,使用component :is 进行匹配渲染
- 注意组件的name名称,使用:is匹配时,需要字段值于之一致
<template v-for="name in comList">
<component :is="name" :key="name"/>
template>
定时器更新图表数据
- 背景
所有图表数据量较多,不宜一次性展示全部,而是分组进行循环展示。即每次展示5条数据,间隔一定时间后切换至下5条数据,以此循环。
- 实现方式
在顶层App.vue组件中,开启一个定时器,并使用 moduleTimerCount
字段记录当前的组别数,按时间间隔更新该字段。并在子组件中监听该字段,该字段变化时计算当前子组件需要显示的数据条数,并更细图表数据。
- 声明
moduleTimerCount
变量
data(){
return {
moduleTimerCount:0
}
}
- 开启一个定时器
- 背景
所有图表数据量较多,不宜一次性展示全部,而是分组进行循环展示。即每次展示5条数据,间隔一定时间后切换至下5条数据,以此循环。
- 实现方式
在顶层App.vue组件中,开启一个定时器,并使用
moduleTimerCount
字段记录当前的组别数,按时间间隔更新该字段。并在子组件中监听该字段,该字段变化时计算当前子组件需要显示的数据条数,并更细图表数据。- 声明
moduleTimerCount
变量
data(){
return {
moduleTimerCount:0
}
}
- 开启一个定时器
- 声明
使用 setTimeout
模拟 setInterval
定时器(相比于setInterval,setTimeout每次执行完当前次任务后才会执行下一次任务,不存在任务堆积问题,每次执行完后自行清理、独立调用,内存泄露的风险较低)。
```JavaScript
function openModuleRefresh(delay) {
const execute = () => {
this.moduleTimerCount += 1;
if (moduleRefreshTime) {
clearTimeout(moduleRefreshTime);
}
moduleRefreshTime = setTimeout(execute, delay * 1000);
};
setTimeout(execute, delay * 1000); // 首次延迟执行
},
```
3. 子组件监听字段变化
```JavaScript
watch: {
moduleTimerCount(value) {
if (dataList) {
// 当前接口数据的数据长度
const dataLength = dataList.length;
// 每5个分一组,计算组别数
const totalGr0up = Math.ceil(dataLength / 5);
// 计算当前组别数,使用 moduleTimerCount 值对组别数取余,保证获取的当前组别不会超过总组别数
this.chartGr0upIndex = value % totalGr0up;
// 计算当前的数据,由组别数获取当前组的数据索引
const startIndex = this.chartGr0upIndex * 5;
let endIndex = (this.chartGr0upIndex + 1) * 5;
if (endIndex >= echartsData.data.length) {
endIndex = echartsData.data.length;
}
// 根据索引截取数据
const renderChartData = echartsData.slice(startIndex, endIndex);
}
},
},
```
来源:juejin.cn/post/7439207153938317339
小程序头像昵称获取“一刀切”式调整,害苦开发者
💬 前言
正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。
我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复杂业务,但是起步没多久微信 API 冷不丁地就给了我一顿暴击(红色警告),这个暴击就是 “小程序用户头像昵称获取规则调整”。
正如标题所言,小程序的用户头像昵称获取规则从2022年的5月调整了,但是这一个改动却害苦了一众开发者。我遇到这个问题,是在9月份开发个人小程序的时候。
我开发的是一个 “微信头像加国旗” 类的小程序,叫做 “星点贴纸”。本以为开发会很顺利,因为几乎没有复杂业务,但是起步没多久微信 API 冷不丁地就给了我一顿暴击(红色警告),这个暴击就是 “小程序用户头像昵称获取规则调整”。
💻 还原业务场景
要讲清楚当时开发过程遇到的问题,就要先代入到业务场景来说明。“星点贴纸” 主要提供给微信头像加各类贴纸的功能,那么这首先就需要获取到用户头像,当然这是功能最直接的实现路径。“星点贴纸” 小程序所能够提供并使用作为头像的方式有:
- 使用微信头像
- 从相册中选择
- 使用相机拍摄
是的,我就是这样规划业务功能的,于是我就实现了点击头像预览区时,弹出选项列表。

要讲清楚当时开发过程遇到的问题,就要先代入到业务场景来说明。“星点贴纸” 主要提供给微信头像加各类贴纸的功能,那么这首先就需要获取到用户头像,当然这是功能最直接的实现路径。“星点贴纸” 小程序所能够提供并使用作为头像的方式有:
- 使用微信头像
- 从相册中选择
- 使用相机拍摄
是的,我就是这样规划业务功能的,于是我就实现了点击头像预览区时,弹出选项列表。


弹出选项列表
编码实现如下
- 在 wxml 中给头像预览区添加点击事件
bind:tap="preAvatarTapped"
。
<view class="avatar-area" bind:tap="preAvatarTapped">
<image class="img-sticker"
mode="{{preAvatar.stickerMode}}"
style="{{preAvatar.stickerPosition}}"
src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
- 在 js 中实现点击后调用
wx.showActionSheet()
,弹出选项列表。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
编码实现如下
- 在 wxml 中给头像预览区添加点击事件
bind:tap="preAvatarTapped"
。
<view class="avatar-area" bind:tap="preAvatarTapped">
<image class="img-sticker"
mode="{{preAvatar.stickerMode}}"
style="{{preAvatar.stickerPosition}}"
src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
- 在 js 中实现点击后调用
wx.showActionSheet()
,弹出选项列表。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
获取用户头像
那么接下来,就是实现 “获取用户微信头像”,于是我在微信小程序 API 中找到了wx.getUserProfile
用于获取用户头像昵称。
Page({
preAvatarTapped(event) {
wx.showActionSheet({
itemList: ["使用微信头像", "从相册中选择", "使用相机拍摄"],
itemColor: "#FFBB66",
success: (res) => {
console.log(res.tapIndex);
if (res.tapIndex === 0) {
wx.getUserProfile({
desc: '用于处理图像',
success: (res) => {
console.log(res.tapIndex);
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
},
fail: (err) => {
console.log(err.errMsg);
}
});
}
});
但是编译测试后发现控制台报错了,获取的头像也是灰色的默认头像。看着错误信息 “jsapi invalid request data”,我以为是请求的参数问题,实际上是接口本身的问题。
📖 阅读文档
起初我也看到了红色的 Tip,但是并没有理会,因为 API 调整我想大概率也就是字段变更之类的。但是找了一圈发现依然解决不了 “jsapi invalid request data” 的问题,索性点进去看看 Tip。
小程序用户头像昵称获取规则调整公告
不看不知道,一看才知道,自2022年10月25日之后基础库 2.27.1 版本以上通过wx.getUserProfile
接口获取用户头像将统一返回默认灰色头像,昵称将统一返回 “微信用户”。如业务需获取用户头像昵称,可以使用「头像昵称填写」能力。
头像昵称填写
Ok,既然官方文档也给出了解决方法,那就换成 “头像昵称填写” API 来实现获取用户微信头像。只不过,这又让我犯了难。
首先官方文档规定 “获取头像昵称” 的开放能力只能通过button
组件的open-type="chooseAvatar"
实现。而我前面所使用的wx.showActionSheet
并不支持给选项添加这样的参数,当然第三方的 UI 组件库是能够实现的。
其次就是 “弹出选项列表” 通过给view
组件添加点击事件实现的,
,而获取头像昵称的开放能力只能通过button
组件实现。
最开始我是循着业务功能的需求尝试解决问题,使用第三方的 UI 组件库来实现 “弹出选项列表”,但是因为 UI 样式及一些参数无法达到预期遂放弃。
于是想着如何给view
实现添加open-type="chooseAvatar"
,经过研究后终于实现了。这里面的难点是只能使用button
组件的前提下又要不影响原本页面设计的样式,如何对button
组件的样式作改动。
这里的思路如下:
- 将按钮组件作为一个遮罩层,覆盖在图片组件上面,这里需要用到定位以及
z-index
实现。 - 将按钮的样式改成透明,可以使用
plain="true"
将按钮镂空。 - 此时按钮的样式还需要去掉边框,使用
border: unset
。
<view class="avatar-area">
<button class="btn-mask" plain="true" open-type="chooseAvatar" bindchooseavatar="getUserAvatar">button>
<image class="img-sticker" mode="{{preAvatar.stickerMode}}" style="{{preAvatar.stickerPosition}}" src="{{preAvatar.sticker}}">
image>
<image class="img-sample" src="{{preAvatar.sample}}">image>
view>
.avatar-area .btn-mask {
position: absolute;
width: 100%;
height: 100%;
border: unset;
z-index: 1;
}
小程序页面实现效果如下:

🫠 放弃使用微信头像
也许大家都以为改用 “头像昵称填写” 后问题已经解决了,但是我却放弃了这个方案。原因很简单,“头像昵称填写” 所获取的头像是一个十分模糊的头像,根本不适用于 “微信头像” 图像处理类业务,包括 “微信头像加国旗” 这样的功能。至于有多糊,我也懒得去看图片尺寸了,因为当时也是被气到模糊了,根本没法用。一路下来就没办法实现 “使用微信头像”——前功尽弃。
🍉 个人见解
微信官方调整 API 虽然见怪不怪,但是却没想到废弃一个 API 也是想做就做。虽然在 “规则” 的调整背景中有一段话 “实践中发现有部分小程序,在用户刚打开小程序时就要求收集用户的微信昵称头像,或者在支付前等不合理路径上要求授权。如果用户拒绝授权,则无法使用小程序或相关功能。在已经获取用户的 openId 与 unionId 信息情况下,用户的微信昵称与头像并不是用户使用小程序的必要条件”,但是请问,在没有获取用户的 openId 与 unionId 信息情况下呢?
微信官方似乎是站在用户隐私的立场做的调整,但如此 “一刀切” 式的调整,那么《小程序用户隐私保护指引》的意义在哪里?如果用户觉得授权不合理自然会举报,而官方则应当要求违规的小程序整改,而不该想当然地废弃一个接口,又临时拿出一个替代接口,属实是又当又立。再退一步讲,即使是用替代接口,也总不该是个功能降级的接口......
言尽于此,最后还是希望微信官方有一天能把这个问题解决。

来源:juejin.cn/post/7436361280586366987
我患上了空指针后遗症
下面这个报错,相信没有任何一个 Java 程序员没有被它折磨过。我们对他的熟悉程度简直超过了 Hello World。 何止是熟悉,那简直是深恶痛绝,以至于我对它都产生了后遗症。
每当本地调试出现这个错误的时候,都恨不得掐一下大腿,然后默默的对自己说:垃圾,还犯这么愚蠢的错误呢?
不知道有多少同学和我一样有这种感受呢?
回想起我之前接手的一个项目,线上出现了问题,当我到了服务器一看日志,只有几个单词,那就是 java.lang.NullPointerException
,那一刻我是头晕目眩,差点一头撞在 27 寸的显示器上。回想上一次出现这种症状,还是几年前挤早高峰的公交车,挤的我双脚离地,外加有点低血糖。
当然主要问题并不是 NLP(NullPointerException),还是要仰仗前辈异常处理的“非常优秀”,异常包裹的严严实实的,只留了java.lang.NullPointerException
这一点点信息。
于是只能打开代码,找到报错的接口,一步步排查,满眼看去,皆可空指针啊。从此之后,空指针异常给我留下了深深的阴影。
好在从 JDK 14之后,NLP 异常不再仅仅是简单的这几个单词了,而会附带更加具体的异常信息,比如对一个赋值为 null 的字符串求长度,能捕捉到下面这样的异常信息:
Cannot invoke "String.length()" because "s" is null
空指针的由来
要说空指针异常,那还不只是 Java 的问题,绝大多数语言都有这个问题,比如 C++、C#、Go,但是也有没有这个问题,比如 Rust 。
空指针最早是编程界的鼻祖级人物 Tony Hoare 引入的,早在 1965年,他设计 ALGOL 60 语言的时候引入了Null 的设计,ALGOL 可谓是 C 语言的祖宗。ALGOL 中的 Null 被后来的众多语言设计者引入,就包括前面提到的这些语言。
Tony Hoare 不仅发明了我们熟悉的 Null,还是令众多算法残废闻风丧胆的快速排序算法(Quick Sort)的发明者,这个算法也是当前世界上使用最广泛的算法之一。
2009年3月他在Qcon技术会议上发表了题为「Null引用:代价十亿美元的错误」的演讲,回忆自己1965年设计第一个全面的类型系统时,未能抵御住诱惑,加入了Null引用,仅仅是因为实现起来非常容易。它后来成为许多程序设计语言的标准特性,导致了数不清的错误、漏洞和系统崩溃,可能在之后40年中造成了十亿美元的损失
如何应对空指针
处理空指针有一些措施,我们常常称之为「防御式编程」,这个说法也很形象,你不防着它,它真的就上来伤害你。
**1、**主动检查空指针,要使用一个变量之前,要检查这个变量是不是空,不是空再操作,比如常用的对字符串判空。
public static boolean isEmpty(CharSequence cs) {
return cs == null || cs.length() == 0;
}
对应的很多字符串工具类都有 isEmpty
、isNotEmpty
、isNotBlank
这种方法。
同样的,还有对于集合的判断,好多工具包都有 CollectionUtil.isEmpty
这样的方法。
为了避免空引用异常,有时候我们写的代码可能想下面这个样子,一步一判空。这样可以提高代码的健壮性和可靠性,但是看上去并不是很美观。
public static String getUserOrderDetail(Integer userId) {
User user = User.getUser(userId);
if (user != null) {
Order order = user.getOrder();
if (order != null) {
Address address = order.getAddress();
if (address != null) {
String detail = address.getDetail();
if (detail != null) {
return detail;
}
}
}
}
return "不好意思,找了半天,没找到";
}
还好,Java 8 中引入的 Optional 类可以简化这个流程。
public static String getUserOrderDetail(Integer userId) {
return Optional.ofNullable(User.getUser(userId))
.map(User::getOrder)
.map(Order::getAddress)
.map(Address::getDetail)
.orElse("不好意思,找了半天,没找到");
}
2、 能不返回 NULL 的话,就尽量不返回 NULL
比如有些获取集合的方法,没有结果的话,可以返回一个空列表。这种方式对于提供给前端或者消费者使用的接口更加适用,返回一个空集合要远比返回一个空更友好。
3、 能抛异常的话,宁可抛异常,也不要返回 NULL
还有一些情况,抛出给调用者一个具体的异常,要比返回一个 NULL 更加能让调用者清楚到底发生了什么。
比如根据一个用户的信息,但是发现用户不存在了,直接返回给调用者一个「用户不存在」的异常信息更明确,而不是返回一个 NULL,让调用方去猜。
还可以看看风筝往期文章
用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了
来源:juejin.cn/post/7438994542769848360
Angular 19 来了,一大波我看不懂的主版本升级!(长文警告)
00. 前言
Angular 19 主版本正式升级,亮点功能如下:
- 控制哪些路由在客户端、服务端或构建期渲染,且在预渲染期解析路由参数
- 核心响应性原语稳定,引入
linkedSignal
等新原语 - 增量水合预览版,支持追求极致性能的用例
- 生活质量提升 - 时间选择器组件、样式 HMR 等等!
01. 为速度而构建
01-1. 增量水合预览版
增量水合(incremental hydration)允许你使用 @defer
语法对模板局部进行注释,指示 Angular 在特定触发器上惰性加载和水合。
Angular 19 中,你可以在任何使用了 SSR 和完整应用水合的应用中尝试新的增量水合。
请在客户端 bootstrap 中指定:
要将增量水合应用到部分模板,请使用:
01-2. 默认启用事件重播
SSR 应用中,用户事件与下载并执行处理该事件代码的浏览器之间存在鸿沟。event dispatch
解决了这个问题。
event dispatch
在初始页面加载期间捕获事件,并在负责处理事件的代码可用时重播这些事件。
通过配置水合 provider,你可以启用事件重播功能:
今天,我们将事件重播升级到稳定版,并默认为所有 SSR 新应用启用此功能!
01-3. 路由级别的渲染模式
Angular 19 提供了一个 ServerRoute
新接口,允许你配置各个路由应该在服务器端渲染、预渲染还是在客户端渲染:
上述示例中,我们指定在服务端渲染 login
路由,在客户端渲染 dashboard
路由,并预渲染其他所有路由。
服务器路由配置是一个新的配置文件,但它使用 globs
组合现有的路由声明,因此你不必复制任何路由。
过去,没有符合人体工程学的方法可以在预渲染时解析路由参数。
现在,可以使用服务器路由配置无缝实现:
由于 Angular 在注入上下文中执行 getPrerenderPaths
,因此你可以使用 inject
在参数解析中重用业务逻辑。
01-4. SSR + Zoneless Angular
Angular 18 实验性支持 zoneless,允许 Angular 不依赖 zone.js 运行。
等待应用的主要原因是待处理的请求和导航,我们决定引入 HttpClient
和 Router
的原语,来延迟将页面发送给用户,直到应用准备就绪。你现在可以在 Angular 19 中尝试这两个包和 zoneless!
此外,我们还提供了一个 RxJS 运算符,用于通知服务堆栈 Angular 仍未完成渲染:
当 subscription
发出新值时,我们将稳定应用,且服务堆栈会将渲染的标记传递给客户端。
02. DX(开发者体验)
02-1. HMR + 即时编辑/刷新
Angular 19 支持开箱即用的样式 HMR(热模块替换),且实验性支持模板 HMR!
之前,每次更改组件的样式并保存文件时,Angular CLI 都会重建应用,并向通知浏览器刷新。
新 HMR 将编译你修改的样式,将结果发送到浏览器,且在不刷新页面和丢失任何状态的情况下修复应用。
Angular 19 默认启用样式 HMR!要尝试模板 HMR,请使用:
要禁用此功能,请将开发服务器选项指定为 "hmr": false
,或者使用:
02-2. standalone 默认为 true
Angular 19 提供了一个 schematic,它将作为 ng update
的一部分运行,并自动删除所有 standalone
指令、组件和管道的 standalone
组件元数据属性,且将所有 non-standalone 抽象的 standalone
设置为 false
。
02-3. 严格执行 standalone
为了帮助你在项目中实施现代 API,我们开发了一个编译器标志,如果发现不是 standalone
的组件、指令或管道,它就会报错。
要在项目中启用它,请配置 angular.json
:
03. 响应性的进化
03-1. 输入、输出和视图查询稳定
我们观察了新的输入、输出和视图查询 API,并将它们升级到稳定版!
为了简化新 API 的采用,我们开发了 schematics,它将转换你现有的输入、输出和视图查询:
请注意,与传统输入相比,signal 输入是只读的,因此如果要设置输入值,则可能需要手动迁移应用的某些部分。
要一次运行所有迁移,你可以使用:
03-2. 引入 linkedSignal
UI 通常仍需跟踪某些更高级状态的可变状态。举个栗子,选择 UI 具有“当前选择”状态,该状态会随着用户进行选择而变更,但如果选项列表变更,那也需要重置。
新增的 linkedSignal
实验性原语创建了一个可写 signal,捕获了这种类型的依赖关系:
linkedSignal
明确了 options
和 choice
之间的关系,而无需求助于 effect
。
新 API 有 2 种形式:一种是上述的简化形式,另一种是高级形式,开发者可以在其中访问之前的 options
和 choice
值。
它还有一个高级 API,允许使用更复杂的逻辑,比如只要用户的 choice
存在于新的 options
列表中,就可以维护用户的 choice
。
03-3. 引入 resource
目前,Angular 的 signals 主要集中在同步数据上:在 signals 中存储状态、computed 值等。
Angular 19 新增 resource()
实验性 API,这是 signals 与异步操作集成的第一步。
resource
是参与 signal 图的异步依赖,你可以将 resource
视为具有三个部分:
request
函数,它表示要根据 signals 发出的确切请求。比如,user
资源可能会计算依赖当前路由的用户 ID 参数的请求。loader
加载器,当请求更改时执行异步操作,并最终返回新值。- 生成的
Resource
实例,它暴露了与可用值通信的 signals 和resource
的加载中、已解析等当前状态。
因为现在许多 Angular 应用都使用 RxJS 来获取数据,我们还在 @angular/core/rxjs-interop
中添加了 rxResource
,它从基于 Observable 的 loader 创建 resource。
参考文献
[1] Angular 官方博客: blog.angular.dev/meet-angula…
来源:juejin.cn/post/7439721466499514418
前端如何优雅通知用户刷新页面?
前言
老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)
老板:新的需求不是上线了嘛,怎么用户看到的还是老的页面呀
窝囊废:让用户刷新一下页面,或者清一下缓存
老板:那我得告诉用户,刷新一下页面,或者清一下缓存,才能看到新的页面呀,感觉用户体验不好啊,不能直接刷新页面嘛?
窝囊废:可以解决(OS:一点改的必要没有,用户全是大聪明)
产品介绍
c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。
c端需要经常进行一些文案调整,一些老版的文字字眼可能会导致一些舆论问题,所以就需要更新之后刷新页面,让用户看到新的页面。
思考问题为什么产生
项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control
,按正常前端重新部署后, 用户重新
访问系统,已经是最新的页面。
但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。
项目是基于vue的spa应用,通过nginx代理静态资源,配置了index.html协商缓存,js、css等静态文件Cache-Control
,按正常前端重新部署后, 用户重新
访问系统,已经是最新的页面。
但是绝大部份用户都是访问页面之后一直停留在此页面,这时候前端部署后,用户就无法看到新的页面,需要用户刷新页面。
产生问题
- 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
- 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
- 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。
- 如果后端接口有更新,前端重新部署后,用户访问老的页面,可能会导致接口报错。
- 如果前端部署后,用户访问老的页面,可能无法看到新的页面,需要用户刷新页面,用户体验不好。
- 出现线上bug,修复完后,用户依旧访问老的页面,仍会遇到bug。
解决方案
- 前后端配合解决
- WebSocket
- SSE(Server-Send-Event)
- 纯前端方案 以下示例均以vite+vue3为例;
- 轮询html Etag/Last-Modified
- 前后端配合解决
- WebSocket
- SSE(Server-Send-Event)
- 纯前端方案 以下示例均以vite+vue3为例;
在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
何同学翻车了,他使用的开源工具原来是这个
1. 前言
何同学的最新作品大家都看了吧,刚刚看到那个视频的时候,不得不承认何同学的脑洞创意,让我深深感受到新奇。我去看的时候,那个视频在B站的播放量已经破八百W了。视频内容是通过一个36W行的备忘录制作了一个很丝滑的视频。里面用到了一个python写的开源工具,是用来把图片通过使用ascii码生成一个文本的图片,何同学视频说这个软件是自己开发的,但是因播放量太大,被网友发现了视频中代码是来自开源软件,因此就导致了这次的翻车。
不吃瓜了,我们还是步入正题吧。
2. ASCII-generator
2.1 介绍
这个开源工具是可以把图片或者是视频通过使用ascii码去把图片或者视频绘画出来,可以看看效果。
原图
ASCII-generator生成的图
2.2 环境准备
- python 3.6
- cv2
- PIL
- numpy
python 3.6安装
可以到python的官网(http://www.python.org/downloads/m…)找到对应的版本,然后下载对应的安装包安装。我觉得这个版本太久,所以我就安装了最新版。下载下来后,按照向导下一步即可。
安装成功验证
cv2安装
使用pip安装,简单快捷,使用pip之前得先安装pip。
pip安装
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
python3 get-pip.py
cv2安装
pip install opencv-python-headless
PIL安装
PIL已经被更先进的库Pillow所取代,因此安装Pillow即可。
pip install Pillow
numpy安装
pip install numpy
2.3 使用ASCII-generator工具
这些工具就可以使用了,只要使用python3去运行这几个脚本就可把图片或者视频生成比较艺术的字符表示的画像。
2.4 代码仓库地址
3. 免安装程序
上面的安装过程对于小白来讲还是有些复杂,不过不用担心,有个大佬分享了可以直接运行的.exe可执行程序,不需要上面的安装python过程,也可以使用这个充满艺术细胞的工具。
虽然是很多年前的东西了,但是现在用起来也还是非常的丝滑,整个exe程序非常小,只有两百多KB。工具的下载链接我放在了文末。
使用起来也是非常的简便的,直接把图片拖拽到软件框里,就可以生成比较艺术的图像了。
如果你发现拖拽图片进软件后,图片生成的效果不好,你也可以通过左下角的框框调整参数,直到你满意为止。
调整好之后,你就可以把制作好的艺术品导出来了,你可以到处成黑白/彩色图片,或者是导出成文本。
4. 附上软件下载链接
来源:juejin.cn/post/7440122922024665126
为什么可以通过process.env.NODE_ENV来区分环境
0.背景
通常我们在开发中需要区分当前代码的运行环境是dev、test、prod环境,以便我们进行相对应的项目配置,比如是否开启sourceMap,api地址切换等。而我们区分环境一般都是通过process.env.NODE_ENV,那么为什么process.env.NODE_ENV可以区分环境呢?是我们给他配置的,还是他可以自动识别呢?
1.什么是process.env.NODE_ENV
process.env
属性返回一个包含用户环境信息的对象。
在node环境中,当我们打印process.env
时,发现它并没有NODE_ENV
这一个属性。实际上,process.env.NODE_ENV
是在package.json的scripts
命令中注入的,也就是NODE_ENV
并不是node自带的,而是由用户定义的,至于为什么叫NODE_ENV
,应该是约定成俗的吧。
2.通过package.json来设置node环境中的环境变量
如下为在package.json文件的script命令中设置一个变量NODE_ENV
。
{
"scripts": {
"dev": "NODE_ENV=development webpack --config webpack.dev.config.js"
}
}
执行对应的webpack.config.js文件
// webpack.config.js
console.log("【process.env】", process.env.AAA);
但是在index.jsx
中也就是浏览器环境下的文件中打印process.env
就会报错,如下:
可以看到NODE_ENV
被赋值为development
,当执行npm run dev
时,我们就可以在 webpack.dev.config.js
脚本中以及它所引入的脚本中访问到process.env.NODE_ENV
,而无法在其它脚本中访问。原因就是前文提到的peocess.env
是Node环境的属性,浏览器环境中index.js文件不能够获取到。
3.使用webpack.DefinePlugin
插件在业务代码中注入环境变量
这个时候我们就存在一个解决方法,通过webpack中的DefinePlugin来设置一个全局变量,这样所有的打包的js文件都可以访问到这个全局变量了。
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
}
使用DefinePlugin注意点
webpack.definePlugins
本质上是打包过程中的字符串替换,比如我们刚才定义的__WEBPACK__ENV:JSON.stringify('packages')
。
在打包过程中,如果我们代码中使用到了__WEPBACK__ENV
,webpack
会将它的值替换成为对应definePlugins
中定义的值,本质上就是匹配字符串替换,并不是传统意义上的环境变量process
注入。
如下图所示:
由上图可知:仔细对比这两段代码第一个问题的答案其实已经很明了了,针对definePlugin
这个插件我们使用它定义key:value
全局变量时,他会将value
进行会直接替换文本。所以我们通常使用JSON.stringify('pacakges')
或者"'packages'"
。
来源:juejin.cn/post/7345760019319390248
Unocss 写 border太费劲?试试这样
在css中, border 是高频使用的一个属性,但它的写法有非常非常多。
按属性分类,border 属性可以分为以下几类:
- border-width:设置边框的宽度。
- border-style:设置边框的样式。
- border-color:设置边框的颜色。
按方向分类,border 属性可以分为以下几类:
- border-top:设置上边框的宽度、样式和颜色。
- border-right:设置右边框的宽度、样式和颜色。
- border-bottom:设置下边框的宽度、样式和颜色。
- border-left:设置左边框的宽度、样式和颜色。
一般情况下我们会直接使用 border 属性,它是一个简写属性,可以同时设置边框的宽度、样式和颜色。
div {
border: 1px solid red;
}
如果我们要单独设置某个方向边框的某个属性,可以使用以下属性:
- border-top-width:设置上边框的宽度。
- border-top-style:设置上边框的样式。
- border-top-color:设置上边框的颜色。
div {
border-top-width: 1px;
border-top-style: solid;
border-top-color: red;
}
我们也可以单独设置某个方向的边框宽度、样式和颜色,可以使用以下属性:
- border-top:设置上边框的宽度、样式和颜色。
- border-right:设置右边框的宽度、样式和颜色。
- border-bottom:设置下边框的宽度、样式和颜色。
- border-left:设置左边框的宽度、样式和颜色。
div {
border-top: 1px solid red;
}
以上的写法,最常用的还是简写方式,如:
- 简写属性:border: 1px solid red;
- 单个方向的属性:border-top: 1px solid red;
在 unocss 中,我们怎么写边框呢?
可以使用 border 的预设,比如:
<div class="b">div>
<div class="b-2px">div>
<div class="b b-solid">div>
<div class="b b-red">div>
<div class="b b-dashed b-red">div>
为什么只设置 boder-width: 1px;
也能看到边框效果呢?这是因为浏览器为每个元素都设置了一个默认的边框样式,只是 boder-width
的默认值是 0px
,所以最少只需要设置 border-width 就能看到边框效果
当然 unocss 预设中边框的写法也可以单独定义每个方向的宽度、样式和颜色,比如
<div class="b-l">div>
<div class="b b-l-dashed">div>
<div class="b b-l-red">div>
<div class="b-l-2px b-l-red b-l-dashed">div>
由上可知 unocss 的 border 预设其实就是将 border-width 、 border-style 和 border-color 分别定义,然后又可以各自组合上 left、right、top 和 bottom,这样就可以控制每一个方向的边框
这样写当然没什么问题,也非常的灵活,但仔细想想是不是过于麻烦了呢,为什么会觉得麻烦呢?原因就是这样写没有利用到 border
的简写方式,比如 左边 2px red dashed 的边框
我们其实是可以简写成这样的:
div {
border-left: 2px dashed red;
}
甚至我们写行内样式也比 b-l-2px b-l-red b-l-dashed
这种写法更简洁易懂
<div style="border-left: 2px dashed red;">div>
那么,有没有办法不写 css 也能做到这么简洁呢,并且还不能损失它的灵活性
当然有,答案就是自定义rules
// unocss配置文件, uno.config.js|ts
import { defineConfig, presetUno } from 'unocss'
const DIRECTION_MAPPIINGS = { t: 'top', r: 'right', b: 'bottom', l: 'left' }
export default defineConfig({
presets: [
presetUno,
],
rules: [
[
/^b(t|r|b|l|d)-(.*)/,
([, d, c]) => {
const direction = DIRECTION_MAPPIINGS[d] || ''
const p = direction ? `border-${direction}` : 'border'
const attrs = c.split('_')
if (
// 属性中不包含 border-style 则默认 solid
!attrs.some((item) =>
/^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(item),
)
) {
attrs.push('solid')
}
// 属性中不包含 border-width 则默认 1px
if (!attrs.some((item) => /^\d/.test(item))) {
attrs.push('1px')
}
return {
[p]: attrs.join(' '),
}
},
],
],
})
怎么用呢?
- 完整的写法
<div class="bd-2px_dashed_red">div>
<div class="bl-2px_dashed_red">div>
<div class="br-2px_dashed_red">div>
- 缺省的写法
border-width 、 border-style 和 border-color 都可以缺省(但最少写一个),border-style 默认 solid,border-width 默认 1px,border-color 默认继承父容器的 color
<div class="bd-2px">div>
<div class="bd-red">div>
<div class="bd-dashed">div>
<div class="bl-2px">div>
<div class="bl-red">div>
<div class="bl-dashed">div>
<div class="bl-2px">div>
<div class="bl-red">div>
<div class="bl-dashed">div>
可以看出这种写法是不是更简洁、更容易理解呢!
为什么 border-width 、 border-style 和 border-color 最少得写一个,全部缺省不是更好吗?
答: unocss 的默认写法就是可以全缺省的,没必要多此一举了,如 b
b-r
b-l
b-t
b-b
为什么用
bd
表示 border 而不用b
?
主要是为了跟 unocss 的默认写法区分开来,其次 bd
也勉强符合 border
语义的简写。
以上就是本篇文章分享的所有内容了,希望对大家有帮助。
关注我,大脸怪将持续分享更多实用知识和技巧
来源:juejin.cn/post/7348473946582646784
hover后元素边框变粗,样式被挤压?一招帮你解决,快收藏备用!
背景简介
大家好,我是石小石!最近开发中遇到这样一个需求:
hover卡片后,边框由原来的1px变成2px,且颜色由灰色变为蓝色。
hover改变样式,这太easy了!
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #e1e5eb;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
但实际做完后,我们会发现一个问题,样式不够丝滑:
hover后元素的内边距发生变化,中间区域尺寸被挤压,从而导致过渡动画很生硬!
这个问题在前端开发中应该比较常见,我就简单分享一下自己的解决方案吧。
如何解决
要想解决这个问题,本质就是让hover前后,中间核心区域的位置不随边框、边距的变化而变化。
场景一:边框从无到有
最简单的场景,就是一开始没有边框,后来有边框。
这种最容易处理,我们只需要给盒子设置和hover后同样粗细的边框,颜色设置透明即可。
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 2px solid transparent;
width: 296px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
场景二:边框粗细发生变化
比较麻烦的场景,如文章一开始说的场景,hover后,边框从1px变成2px。这种情况,hover盒子的padding一定会变化(注意大盒子尺寸是固定的),必然会导致内部元素被挤压,位置改变。
动态padding
当然,聪明的你可能计算hover后的padding
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
}
}
不加过渡动画时,看着挺不错
但加上transition过渡效果,那就原形毕露!
.work-order-card {
padding: 8px 16px 16px 16px;
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
transition: all 0.2s ease;
&:hover {
padding: 7px 15px 15px 15px;
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
}
不设置padding,居中核心内容
如果盒子的尺寸都能确定,最好的方式,还是使用flex布局,让中间的核心区域(下图红色部分)永远居中!这样,无论边框怎么变,中间的位置永远不变,自然就解决了元素被挤压的问题!
<div class="work-order-card">
<div class="center-box">
<!-- 子元素 -->
</div>
</div>
.work-order-card {
border-radius: 8px;
border: 1px solid #E1E5EB;
width: 296px;
height: 214px;
transition: all 0.2s ease;
&:hover {
border: 2px solid #64A6F7;
transition: all 0.2s ease;
}
.center-box{
width: 264px;
}
}
注意:这种实现方式,要求最外层的盒子宽高是固定的,内部盒子宽度也需要固定。
总结
针对hover某个元素,其边框变粗导致内部元素被挤压的问题,这篇文章提供了三个解决方案:
- 边框从无到有,改变原始边框透明度即可
- 边框hover尺寸变化:
- 如果不要求过渡效果,hover后可以计算padding
- 如果需要过渡效果,使用felx布局居中核心区域即可
如果大家有更好的方案,可以评论区分享一下。
来源:juejin.cn/post/7431999862919921675
autoUno:最直觉的UnoCSS预设方案
起因
可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。
于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:

只有带鱼屏才装得下。
而实际上的原子化:

在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。
举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。
可能你跟我一样头一次听说原子化CSS时,觉得写预设 class 听起来是一件极蠢的事,感觉这是在开倒车,因为我们都经历过 Bootstrap(其实不属于原子化) 的时代。
于是在这个概念刚刚在国内爆火的时候,我对其是嗤之以鼻的,当时我想象中的原子化:
只有带鱼屏才装得下。
而实际上的原子化:
在实际使用中,我们往往不会将所有的样式都使用原子化实现(当然也可以这么干)。
举一个例子,在你开发时,你按照自己习惯,做了一个近乎完美的布局,你的 class 已经写的非常棒,页面看起来赏心悦目,而此时,产品告诉你要在某个按钮的下面加一句提示,为了不破坏你的完美代码,又或者是样式无需太多的 css,你可能会选择直接写行内样式。此时原子化的魅力就体现了出来,只需要简单的寥寥几字,就把准确的 css 表达出来了,而无需再抽出一个无意义的 class。
为什么是 UnoCSS
在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。
在 tailwindCSS、windiCSS 之后,一位长发飘飘的帅小伙,发布了一款国产原子化工具 UnoCSS。虽然大家可能很熟悉它,我还是想啰嗦几句。
UnoCSS 的优势
CSS原子化在前端的长河中,可谓是一个婴儿:
“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。
ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。
Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。
而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性和插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。
这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!
CSS原子化在前端的长河中,可谓是一个婴儿:
“原子化 CSS”(Atomic CSS)的概念最早可以追溯到 2014 年,由 Nicolas Gallagher 在他的博客文章 “About HTML semantics and front-end architecture” 中提出。他在文章中提到了一种新的 CSS 方法论,即使用“单一功能类”(Single-purpose Classes)来替代传统的基于组件或块的样式管理方式。这种方法的核心思想是,将每一个 CSS 类设计为仅包含一种样式规则或一组简单的样式,以便更好地复用和组合样式,从而减少冗余代码。这一思想成为后来原子化 CSS 的基础。同年,第一个原子化框架 ACSS(Atomic CSS)发布了,由 Yahoo 团队开发。
ACSS 的推出激发了 Utility-First CSS 框架的兴起,最终在 Tailwind CSS 等项目中得到广泛应用。
Tailwind 和 Windi CSS 虽然也支持自定义,但它们的定制性主要体现在配置文件的扩展上,如自定义颜色、间距、字体、断点等,且在设计上仍然偏向于固定的原子类名体系。这两者可以通过配置文件生成新的实用类名,这种方式显然使他们有了不可避免的局限性。
而 UnoCSS 则有着高度定制化的特性,主要体现在它的灵活性和插件化设计,使其可以自由定义和扩展类名、行为,甚至能模拟其他 CSS 框架。相比之下,Tailwind CSS 和 Windi CSS 在设计上更偏向于固定的、基于配置的实用类体系,而 UnoCSS 则提供了更多自由度。
这样的设计也使得 UnoCSS 有着天然的性能优势,UnoCSS 支持基于正则表达式的动态类名解析,允许开发者定义自定义的样式规则。例如,可以通过简单的正则规则为特定样式创建动态的类,而不需要预先定义所有的类名。这使得 UnoCSS 的 CSS 小而精,据官网介绍,它无需解析,无需AST,无需扫描。它比Windi CSS或Tailwind CSS JIT快5倍!
原子化的通病
从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。
在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。
比如在 VSCode 中的 UnoCSS 扩展


它可以在 HTML 中提示开发者这个类名下将解析出的 css

也可以进行自动补全。
是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。
从原子化的概念本身出发,我们不难发现,这种做法有一种通病,就是我除了要知道基本的 CSS 之外,还需要知道原子化类库的预定义值,也就是说,我们需要提前知道写哪些 class 是有效的,哪些是无法识别的。
在现代化编辑器中,我们可以使用编辑器扩展来识别这些类名。
比如在 VSCode 中的 UnoCSS 扩展
它可以在 HTML 中提示开发者这个类名下将解析出的 css
也可以进行自动补全。
是的这很方便,但是我们依旧要大概知道这些 预设 class 的写法,对其不熟悉的的用户,可能还要翻阅文档来书写。
全自动的 UnoCSS
我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高
按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?
我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:

嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。
先看一下传统的字面量 Uno 预设
我就在想,为什么没有一个原子化库,可以支持智能识别呢,比如我想实现一个行高
按照上图中的预设,我需要依次打出 l、i、n、e、-,才匹配到了第一个和行高有关的属性,如果情况再搞笑一点,我根本不知道 line 怎么写怎么办?
我相信很多同学可能会有共情,因为我们在写传统 CSS 时,一般是打出我们自己熟悉的几个字母,依靠编辑器的自动补全(emmet)来做的,像这样:
嗯,看起来很舒服,只需要打出少量的字母,就可以识别到了。
先看一下传统的字面量 Uno 预设
传统预设

我们可以自定义一些个人比较熟悉的简写。
或者写一些正则,来支持更复杂的数值插入等

好吧,看到这我都上不来气儿了,这我要写到什么时候去?
确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设

好吧,可能有,但是太多了,且大多是一些个性化的实现。
我们可以自定义一些个人比较熟悉的简写。
或者写一些正则,来支持更复杂的数值插入等
好吧,看到这我都上不来气儿了,这我要写到什么时候去?
确实,一个一个的去自定义规则,花费了非常多的精力和时间,那我们看一下社区有没有提供相对通用的规则呢, UnoCSS社区预设
好吧,可能有,但是太多了,且大多是一些个性化的实现。
autoUno 预设方案
于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:
- line-height1px
- lh24px
- lh1
- lh1rem
- lineh1
- lihei1
- ...等等你习惯的写法
于是我准备手动做一个类似 emmet 补全的预设,希望它可以做到识别任意写法,比如:
- line-height1px
- lh24px
- lh1
- lh1rem
- lineh1
- lihei1
- ...等等你习惯的写法
正则拦截几乎所有写法
字母+数字
/^[a-zA-Z]+(\d+)$/
字母+数字+单位
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/
字母+颜色
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/
字母+冒号+字母
/^[a-zA-Z]+:+[a-zA-Z]$/
也就是说,我们的 rules 会长这样:
rules: [
[
/^[a-zA-Z]+(\d+)$/,
([a, d]) => {
const [property, unit] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${unit || ''}` }
}
],
[
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
([a, d, u]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${u}` }
}
],
[
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
([a, c]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: c }
}
],
[
/^[a-zA-Z]+:+[a-zA-Z]$/,
([a]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
const propertyName = property.split(':')[0]
const propertyValue = property.split(':')[1]
return { [propertyName]: propertyValue }
}
],
]
接下来,只要实现 findBestMatch 方法就好了。
正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的
- 匹配顺序一致
- 至少命中 2 字符
- 可以自定义单位
字母+数字
/^[a-zA-Z]+(\d+)$/
字母+数字+单位
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/
字母+颜色
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/
字母+冒号+字母
/^[a-zA-Z]+:+[a-zA-Z]$/
也就是说,我们的 rules 会长这样:
rules: [
[
/^[a-zA-Z]+(\d+)$/,
([a, d]) => {
const [property, unit] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${unit || ''}` }
}
],
[
/^[a-zA-Z]+(\d+)+(vh|vw|px|rem|em|%)$/,
([a, d, u]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: `${d || ''}${u}` }
}
],
[
/^[a-zA-Z-]+(#[a-zA-Z0-9]+)$/,
([a, c]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
return { [property]: c }
}
],
[
/^[a-zA-Z]+:+[a-zA-Z]$/,
([a]) => {
const [property] = findBestMatch(a, customproperty)
if (!property) return
const propertyName = property.split(':')[0]
const propertyValue = property.split(':')[1]
return { [propertyName]: propertyValue }
}
],
]
接下来,只要实现 findBestMatch 方法就好了。
正如刚刚提到的,我们需要模拟一个 emmet 的提示,规则大概是这样的
- 匹配顺序一致
- 至少命中 2 字符
- 可以自定义单位
那么我们可以先列举一下可能用到的 CSS 属性(全部大概有350个左右)
const propertyCommon = [
"display: flex",
"display: block",
"display: inline",
"display: inline-block",
"display: grid",
"display: none",
// "...":"..." 还有更多
]
比如我希望 输入 d:f
就自动帮我匹配到 display: flex
。
那么逻辑应该是这样的:
获取到第一个字符 d
,让它分别去这些字符串中比较,比如 display: flex
将被分解成 d
、i
、s
...
首先匹配到第一个字符 d 发现一致,那么 display: flex
的可能性就 + 1,整个遍历下来,顺序一致,且命中字符数最多的,就是我们要找的,很显然 输入 d:f
命中最多的应该是 display: flex
,分别是 d
、:
、f
,此时函数返回就正确了。
findBestMatch 方法实现
除了刚刚列举的常用固定写法,还有一些带单位的属性,我选择用 $
符号分割,以便于在函数中提取
const propertyWithUnit = [
"animation-delay$ms",
"animation-duration$ms",
"border-bottom-width$px",
"border-left-width$px",
"border-right-width$px",
"border-top-width$px",
"border-width$px",
"bottom$px",
"box-shadow$px",
"clip$px",
// ... 更多
]
我们在预设属性中,使用 $ 符号隔断了一个默认单位,一会将在函数中提取它。
export function findBestMatch(input: string, customproperty: string[] = []) {
// 将输入字符串转换为字符数组
const inputChars = input.split('')
let bestMatch: any = null
let maxMatches = 0
// 遍历所有目标字符串
for (let keywordOrigin of customproperty.concat(propertyWithUnit.concat(propertyCommon))) {
const keyword = keywordOrigin.split('$')[0]
// 用来记录目标字符串的字符序列是否匹配
let matchCount = 0
let inputIndex = 0
// 遍历目标字符串
for (let i = 0; i < keyword.length; i++) {
// 如果第一个字符就不匹配,直接跳过
if (i === 0 && keyword[i] !== inputChars[0]) {
break
}
if (inputIndex < inputChars.length && keyword[i] === inputChars[inputIndex]
&& (input.includes(":") && keyword.includes(":") || (!input.includes(":")))) {
matchCount++
inputIndex++
}
}
// 如果找到的匹配字符数大于等于 2,且比当前最大匹配数多
if (matchCount >= 2 && matchCount > maxMatches) {
maxMatches = matchCount
bestMatch = keywordOrigin
}
}
let unit: any = ''
// 用正则匹配单位,最后一个数字的后面的字符
const unitMatch = input.match(/(\d+)([a-zA-Z%]+)/)
unit = unitMatch && unitMatch[2]
if (!unit && bestMatch && bestMatch.split('$')[1]) {
unit = bestMatch.split('$')[1]
}
return [bestMatch && bestMatch.split('$')[0], unit]
}
此函数使用了一种加分机制,去寻找最匹配的字符,当用户传入一个 class 时,将从第一个字符开始匹配,第一个不匹配直接跳过(遵循emmet规则,也有利于性能),接着,在是否加分的的 if 中,需要判断是否包含 :
,这是为了区分是否是带冒号的常用属性(区别于带单位的属性)。
在循环中,将找出最匹配的预设属性值,最后,判断用户输入的字符串是否带单位,如果带单位就使用用户单位,如果没有,就使用默认单位(预设属性中 $ 符号后面的字符)。
然后返回一个数组,它将是 [property,unit]
其实在上面的正则中,我将带单位和不带单位的匹配分开了,在写这篇文章时,findBestMatch 函数我还没想好怎么改😅,于是就先将就着讲给各位看,核心思想是一样的。
如此一来,我们无需自定义过多的固定 rules,只需要补充一些CSS属性就可以了,接下来你的UnoCSS 规则将长这样:
export default defineConfig({
presets: [
autoUno([
'border-radius$px',
"display:flex",
"...."
])],
})
只需列举你将用到的标准css属性即可,含有数值的,以$符号分隔默认单位,其实你也无须过多设置,因为我的 autoUno 预设中已经涵盖了大部分常用属性,只有你发现 autoUno 无法识别你的简写时,才需要手动传入。
接下来,隆重介绍
autoUno
autoUno 是 UnoCSS 的一个预设方案,它支持你以最直觉的方式设置 class 。
你认为对,它就对,再也不受任何预设的影响,再也不用记下任何别人定义的习惯。
此项目已在 github 开源:github.com/Auto-Plugin…
此项目在 NPM 可供下载:http://www.npmjs.com/package/aut…
官方网站(可在线尝试):auto-plugin.github.io/index/autou…
安装
pnpm i autouno
pnpm i autouno
使用
import { defineConfig } from 'unocss'
import autoUno from 'autouno'
export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})
作者:德莱厄斯
来源:juejin.cn/post/7435653910252191754
import { defineConfig } from 'unocss'
import autoUno from 'autouno'
export default defineConfig({
presets: [
autoUno([
"box-shadow:none",
])],
})
来源:juejin.cn/post/7435653910252191754
如何在鸿蒙ArkTs中进行全局弹框
背景
刚接触鸿蒙开发不久,从iOS转过来的,经常会遇到在一个公共的类里,会想要给当前window上添加一个全屏的自定义视图,那在鸿蒙中应该如何实现这一个效果呢?
这里介绍一下我自己想到的实现方式,不一定是最优解,大家有其他更好的方式或者问题,欢迎指正。
代码是基于鸿蒙next和模拟器
思路
在鸿蒙中,虽然可以通过下面的系统方法获取到window
,但是目前我不知道如何像iOS一样,在其上添加自定义的组件。所以,在研究了系统的window
之后,想到是否可以直接弹出一个全屏的window
,然后在这个自定义的window
上,添加我们的自定义组件。类似于iOS的三方库SwiftEntryKit
import { window } from '@kit.ArkUI'
function findWindow(name: string): Window;
实现步骤
- 通过调用
createWindow
函数,创建一个自定义的window
,并设置windowType
枚举为
TYPE_DIALOG
,这个是一个API10之后有的类型。 - 通过调用
loadContent(path: string, storage: LocalStorage, callback: AsyncCallback<void>): void
创建一个指定的页面作为这个window
的根视图,我们后面自己的自定义弹框组件,都是加载到这个页面中。第二个参数storage
也很重要,因为通过该方法指定了页面,但是无法将自定义的参数直接传入到页面中,所以通过LocalStorage
进行中转传值。 - 在需要进行传值的属性中,非常重要的是一个
entry?: CustomBuilder
自定义组件的属性,因为我们毕竟是要封装一个通用的类,去支持你传入任意的自定义视图。这里有个非常重要的点:在class中传入的这个属性,是一个代码块,里面是我们自定义的组件代码,但是我们无法在page中,直接去执行这个代码块,来获取到相应的布局。这里其实还需要在page的代码中新增一个属性@BuilderParam entryView: CustomBuilder
,这个应该很熟悉,就是如果我们是直接初始化一个包含这个属性的组件时,就可以直接传入一个@Builder function()
自定义组件,并且内部可以直接使用。那我们这里需要做的就是,在page的aboutToAppear
中,将我们传入的参数,赋值给这个页面声明的属性,这样就可以在布局中去加载这个布局了。 - 传入到页面中的参数,还可以包含布局/动画等参数,这里只实现了布局,后续可以继续完善动画相关方法
- 最后在传入这个布局代码的时候,如果有自定义的点击事件,需要注意
this
的绑定当前调用方。
代码
公共模块:
import { window } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'
export class HDEntryKit {
static display(use: EKAttributes) {
HDWindowProvider.instance().display(use)
}
static dismiss(complete?: (() => void)) {
HDWindowProvider.instance().dismiss(complete)
}
}
class HDWindowProvider {
private static windowProvider: HDWindowProvider
context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
windowName: string = "HDEntryWindow"
static instance() {
if (!HDWindowProvider.windowProvider) {
HDWindowProvider.windowProvider = new HDWindowProvider();
}
return HDWindowProvider.windowProvider;
}
display(use: EKAttributes) {
let windowClass: window.Window
window.createWindow({
name: this.windowName,
windowType: window.WindowType.TYPE_DIALOG,
ctx: this.context
}, (err, data) => {
if (err.code == 0) {
windowClass = data
windowClass.setWindowLayoutFullScreen(true)
let bundleName = this.context.applicationInfo.name
let page = `@bundle:${bundleName}/uicomponents/ets/HDEntryKit/HDEntryPage`
let storage: LocalStorage = new LocalStorage()
storage.setOrCreate('use', use)
windowClass.loadContent(page, storage, err => {
if (err.code == 0) {
windowClass.setWindowBackgroundColor(use.backgroundColor?.toString())
}
})
windowClass.showWindow(() => {
})
}
})
}
dismiss(complete?: (() => void)) {
window.findWindow(this.windowName).destroyWindow((err, e) => {
if (err.code == 0 && complete) {
complete()
}
})
}
}
export class Size {
width: Length | null = null
height: Length | null = null
margin: Length | Padding = 0
}
export class EKAttributes {
name?: string
entry?: CustomBuilder
position: FlexAlign = FlexAlign.Center
backgroundColor: ResourceColor = "#99000000"
displayDuration: number = 1000
size: Size = new Size()
}
import { EKAttributes, HDEntryKit } from './HDEntryKit'
let storage = LocalStorage.getShared()
@Entry(storage)
@Component
struct HDEntryPage {
@BuilderParam entryView: CustomBuilder
@LocalStorageProp('use') use: EKAttributes = new EKAttributes()
build() {
Column() {
Row() {
Column() {
if (this.entryView) {
this.entryView()
}
}
.width('100%')
.onClick(e => {})
}
.width(this.use.size.width)
.height(this.use.size.height)
.margin(this.use.size.margin)
.backgroundColor(Color.Blue)
}
.width('100%')
.height('100%')
.justifyContent(this.use.position)
.onClick(event => {
HDEntryKit.dismiss()
})
}
aboutToAppear(): void {
this.entryView = this.use.entry
}
调用方:
/// 弹框的配置
let use = new EKAttributes()
use.size.height = 100
use.size.margin = 20
use.position = FlexAlign.End
use.entry = this.text.bind(this)
HDEntryKit.display(use)
/// 自定义的弹框组件
@Builder text() {
Row() {
Text("123")
.backgroundColor('#ff0000')
.onClick(() => {
this.test()
})
}
.width('100%')
.justifyContent(FlexAlign.Start)
}
/// 弹框组件中的页面跳转事件
test() {
HDEntryKit.dismiss(() => {
let bundleName = this.context.applicationInfo.name
let loginPage = `@bundle:${bundleName}/login/ets/pages/LoginRegisterPage`
router.pushUrl({url: loginPage})
})
}
注意
通过自定义window
方法弹出页面后,如果在调用router.push
,则是默认在这个自定义的window
进行页面跳转,当你销毁这个window
的时候,打开的页面都会被关闭。所以,在demo里是在window
销毁后,再进行页面跳转
来源:juejin.cn/post/7342038143162466340
已有Flutter项目如何支持鸿蒙系统
背景
现在越来越多的项目使用Flutter
来做跨平台开发。比如我们的Fanbook
App,同时支持iOS和安卓客户端, 目前95%以上的代码都是使用Dart
来开发的,另外5%是一些原生插件支持,比如图片选择器、拍照、数据库等,使用了ObjectC
,Java
开发。随着鸿蒙纯血原生系统的推进,有越来越多的应用都在紧锣密鼓的研发支持鸿蒙系统的客户端,当然也包括我们的Fanbook
项目。
在技术调研过程中,发现了OpenHarmony SIG
组织,用于孵化OpenHarmony
相关开源生态项目,他们在很早就开始推进Flutter
支持鸿蒙系统的工作。目前项的仓库地址。该仓库是基于Flutter SDK
对于OpenHarmony
平台的兼容拓展,可支持IDE或者终端使用Flutter Tools
指令编译和构建OpenHarmony
应用程序。既然这样,我们就没必要使用ArkTs、ArkUI
来从零开始研发鸿蒙系统应用。
现在越来越多的项目使用Flutter
来做跨平台开发。比如我们的Fanbook
App,同时支持iOS和安卓客户端, 目前95%以上的代码都是使用Dart
来开发的,另外5%是一些原生插件支持,比如图片选择器、拍照、数据库等,使用了ObjectC
,Java
开发。随着鸿蒙纯血原生系统的推进,有越来越多的应用都在紧锣密鼓的研发支持鸿蒙系统的客户端,当然也包括我们的Fanbook
项目。
在技术调研过程中,发现了OpenHarmony SIG
组织,用于孵化OpenHarmony
相关开源生态项目,他们在很早就开始推进Flutter
支持鸿蒙系统的工作。目前项的仓库地址。该仓库是基于Flutter SDK
对于OpenHarmony
平台的兼容拓展,可支持IDE或者终端使用Flutter Tools
指令编译和构建OpenHarmony
应用程序。既然这样,我们就没必要使用ArkTs、ArkUI
来从零开始研发鸿蒙系统应用。
开整
先定个基调,把原有Flutter
项目,新增支持鸿蒙系统,其实比想象简单。
先定个基调,把原有Flutter
项目,新增支持鸿蒙系统,其实比想象简单。
配置Flutter环境
下载OpenHarmony
组织提供的Flutter
仓库
git clone https://gitee.com/openharmony-sig/flutter_flutter.git
clone下载完成之后,可以切换到master
或者dev
分支, dev
更新会及时些,现阶段可以作为学习分支。
然后配置环境变量
export PATH="$PATH":"/pathtoflutter/bin"
在终端输入命令行 flutter doctor -v
, 如果检查成功则代表针对鸿蒙系统的Flutter
环境配置没问题。

下载鸿蒙开发工具DevEco Studio
, 这个之前的文章提过了,不再多说。
环境搭建好,话不多说,开始写代码
下载OpenHarmony
组织提供的Flutter
仓库
git clone https://gitee.com/openharmony-sig/flutter_flutter.git
clone下载完成之后,可以切换到master
或者dev
分支, dev
更新会及时些,现阶段可以作为学习分支。
然后配置环境变量
export PATH="$PATH":"/pathtoflutter/bin"
在终端输入命令行 flutter doctor -v
, 如果检查成功则代表针对鸿蒙系统的Flutter
环境配置没问题。
下载鸿蒙开发工具DevEco Studio
, 这个之前的文章提过了,不再多说。
环境搭建好,话不多说,开始写代码
开始实操
在github随便找了一个项目为例, 还行先把项目clone下来
git clone https://github.com/jayden320/flutter_shuqi
cd flutter_shuqi
clone成功之后,使用Android Studio
打开项目。

git clone https://github.com/jayden320/flutter_shuqi
cd flutter_shuqi
clone成功之后,使用Android Studio
打开项目。
在一个空白目录执行以下命令,创建一个同名的项目
flutter create --platforms ohos,ios,android flutter_shuqi
cd flutter_shuqi
flutter create --platforms ohos,ios,android flutter_shuqi
cd flutter_shuqi
进入新项目的目录,发现多了一个鸿蒙系统代码的文件夹,然后把这个ohos
复制到第一步clone下来的目录
再回到第一步使用Android Studio
打开的项目,可以发现多了一个ohos
文件夹。
链接鸿蒙系统真机或者模拟器,执行flutter run
可能有些伙伴会有疑问,为什么把文件夹复制过来就可以正常运行了,那是因为我们目前使用的是鸿蒙提供了Flutter, 他们对Flutter Tools
进行了修改,当使用flutter pub get
、flutter run
等命令。这些命令行的内部已经帮我们做了这些事情,他会去自动查找ohos
目录,并生成相应的代码和.har
包,从而确保可以支持鸿蒙系统。
这个时候,正常会报签名错误。看下面的截图可以发现,修复方式,就是使用DevDco Studio
打开flutter_shuqi/ohos
项目就行自动化签名即可。
开始自动签名
进入下面的操作面板,使用自己华为开发者账号登录之后勾选自动生成签名即可。
再次flutter run
签名成功之后在回到Android Stuido,再链接鸿蒙系统真机flutter run
。这就说明已经成功跑起来了。
正常情况下会遇到一些问题,导致项目可以在鸿蒙系统上跑起来,但是显示空白。
解决页面空白
修改environment中的sdk版本
因为OpenHarmony SIG
是基于Flutter 3.7.12
版本修改的,如果有些项目中使用了更高的版本,需要修改pubspec.yaml
文件,把sdk
环境最低版本降到2.19.6
以下就行。
environment:
sdk: '>=2.17.0 <3.0.0'
2. ### 如何判断是鸿蒙系统
import 'dart:io';
static bool get isOHOS => Platform.operatingSystem == "ohos"
3. ### 第三方库没有支持鸿蒙系统,怎么办?
比如常见的shared_preferences
,device_info
,path_provider
,这些库一般的Flutter
项目都会使用,所以OpenHarmony SIG
组织已经对这些库做了一些支持。大家可以点击查看。
然后如下方式进行修改就行。修改完了之后执行 flutter pub get
更新本地代码。
改了上面的shared_preferences
库,就可以正常进入项目了,不过显示图片还有点问题。

图片显示不出来,一般都是path_provider
的问题,因为图片需要缓存到本地沙盒,相应改一下就行,不过有时候,有些库相互引用,导致修改比较麻烦,好在Flutter
提供了提供了dependency_overrides
方式,可以覆盖第三方库的最终地址。里面覆盖了path_provider
、package_info_plus
、permission_handler
、device_info_plus
、connectivity_plus
。这些库都是鸿蒙开源组织已经修改好了的。
dependency_overrides:
path_provider:
git:
url: https://gitee.com/openharmony-sig/flutter_packages.git
path: "packages/path_provider/path_provider"
package_info_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins.git
path: packages/package_info_plus/package_info_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c
permission_handler:
git:
url: https://gitee.com/openharmony-sig/flutter_permission_handler.git
path: permission_handler
device_info_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins.git
path: packages/device_info_plus/device_info_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c
connectivity_plus:
git:
url: https://gitee.com/openharmony-sig/flutter_plus_plugins
path: packages/connectivity_plus/connectivity_plus
ref: a1347adcca3a46346a6ddd127cebcec9970cad6c
flutter pub get
之后,再次运行,基本上就可以使用。本来想放视频演示下,结果上传视频比较麻烦,就截了视频里面的几张图片,可以大致看看效果。想自己跑的话,下文也把源码push到gitee了。



支持鸿蒙的仓库已经提交到该地址。感谢原作者,我拿过来只是为了演示项目。
总结
大概花了不到半天的时间,就可以把一个已有的Flutter
项目来支持原生鸿蒙系统,这个迁移成本还是不太高的,对于一些纯Dart
写的第三方库可以直接使用,也无需适配。当然还会有一些其他的问题,比如鸿蒙没有覆写的第三方插件库,还有一些鸿蒙系统专属特性,这就需要我们自己去写一些鸿蒙原生代码,但是其实难度也不高。
来源:juejin.cn/post/7405153695539396617