注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

flutter 响应式观察值并更新UI

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。 Observables Observables是响应式编程的核心。这些数据...
继续阅读 »

响应式编程是一种以对数据随时间变化做出反应为中心的范式。它有助于自动传播更新,从而可确保 UI 和数据保持同步。用 Flutter 术语来说,这意味着只要状态发生变化就会自动触发重建。


Observables


Observables是响应式编程的核心。这些数据源会在数据发生变化时向订阅者发出更新。Dart 的核心可观察类型是 Stream。


当状态发生变化时,可观察对象会通知侦听器。从用户交互到数据获取操作的任何事情都可以触发此操作。这有助于 Flutter 应用程序实时响应用户输入和其他更改。


Flutter 有两种类型:ValueNotifierChangeNotifier,它们是类似 observable 的类型,但不提供任何真正的可组合性计算。


ValueNotifier


Flutter 中的类ValueNotifier在某种意义上是响应式的,因为当值发生变化时它会通知观察者,但您需要手动监听所有值的变化来计算完整的值。


1、监听


  // 初始化
final ValueNotifier<String> fristName = ValueNotifier('Tom');
final ValueNotifier<String> secondName = ValueNotifier('Joy');
late final ValueNotifier<String> fullName;

@override
void initState() {
super.initState();
fullName = ValueNotifier('${fristName.value} ${secondName.value}');

fristName.addListener(_updateFullName);
secondName.addListener(_updateFullName);
}

void _updateFullName() {
fullName.value = '${fristName.value} ${secondName.value}';
}


//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用ValueListenableBuilder更新UI


 //通知观察者
ValueListenableBuilder<String>(
valueListenable: fullName,
builder: (context, value, child) => Text(
'${fristName.value} ${secondName.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
),

ChangeNotifier


1、监听


  String _firstName = 'Jane';
String _secondName = 'Doe';

String get firstName => _firstName;
String get secondName => _secondName;
String get fullName => '$_firstName $_secondName';

set firstName(String newName) {
if (newName != _firstName) {
_firstName = newName;
// Triggers rebuild
notifyListeners();
}
}

set secondName(String newSecondName) {
if (newSecondName != _secondName) {
_secondName = newSecondName;
// Triggers rebuild
notifyListeners();
}
}

//更改值得时候
firstName.value = 'Jane'
secondName.value = 'Jane'

2、使用AnimatedBuilder更新UI


//通知观察者
AnimatedBuilder(
animation: fullName,
builder: (context, child) => Text(
fullName,
style: Theme.of(context).textTheme.headlineMedium,
),
),

get


GetX将响应式编程变得非常简单。



  • 您不需要创建 StreamController。

  • 您不需要为每个变量创建一个 StreamBuilder。

  • 你不需要为每个状态创建一个类。

  • 你不需要创造一个终极价值。


使用 Get 的响应式编程就像使用 setState 一样简单。
让我们想象一下,您有一个名称变量,并且希望每次更改它时,所有使用它的小组件都会自动刷新。


1、监听以及更新UI


//这是一个普通的字符串
var name = 'Jonatas Borges';
为了使观察变得更加可观察,你只需要在它的附加上添加“.obs”。
var name = 'Jonatas Borges'.obs;
而在UI中,当你想显示该值并在值变化时更新页面时,只需这样做。
Obx(() => Text("${controller.name}"));

Riverpod


final fristNameProvider = StateProvider<String>((ref) => 'Tom');
final secondNameProvider = StateProvider<String>((ref) => 'Joy');
final fullNameProvider = StateProvider<String>((ref) {
final fristName = ref.watch(fristNameProvider);
final secondName = ref.watch(secondNameProvider);
return '$fristName $secondName';
});

//更改值得时候
ref.read(fristNameProvider.notifier).state =
'Jane'
ref.read(secondName.notifier).state =
'BB'


2、使用ConsumerWidget更新UI


ref.read(surnameProvider) 读取某个值


ref.read(nameProvider.notifier).state 更新某个值的状态


class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) =>
Scaffold(
appBar: AppBar(
title: const Text('Riverpod Example'),
),
body: Text(
ref.watch(fullNameProvider),
style: Theme.of(context).textTheme.headlineMedium,
),
);
}


这里Consumer组件是与状态交互所必需的,Consumer有一个非标准build方法,这意味着如果您需要更改状态管理解决方案,您还必须更改组件而不仅仅是状态。


RxDart


RxDart将ReactiveX的强大功能引入Flutter,需要明确的逻辑来组合不同的数据流并对其做出反应。


存储计算值:它不会以有状态的方式直接存储计算值,但它确实提供了有用的运算符(例如distinctUnique)来帮助您最大限度地减少重新计算。


RxDart 库还有一个流行的类型被称为BehaviorSubject。响应式编程试图解决的核心问题是当依赖图中的任何值(依赖项)发生变化时自动触发计算。如果有多个可观察值,并且您需要将它们合并到计算中,Rx 库自动为我们执行此操作并且自动最小化重新计算以提高性能。


该库向 Dart 的现有流添加了功能。它不会重新发明轮子,并使用其他平台上的开发人员熟悉的模式。


1、监听


 final fristName = BehaviorSubject.seeded('Tom');
final secondName = BehaviorSubject.seeded('Joy');

/// 更新值
fristName.add('Jane'),
secondName.add('Jane'),


2、使用StreamBuilder更新UI


 StreamBuilder<String>(
stream: Rx.combineLatest2(
fristName,
secondName,
(fristName, secondName) => '$fristName $secondName',
),
builder: (context, snapshot) => Text(
snapshot.data ?? '',
style: Theme.of(context).textTheme.headlineMedium,
),
),

Signals


Signals以其computed功能介绍了一种创新、优雅的解决方案。它会自动创建反应式计算,当任何依赖值发生变化时,反应式计算就会更新。


1、监听


  final name = signal('Jane');
final surname = signal('Doe');
late final ReadonlySignal<String> fullName =
computed(() => '${name.value} ${surname.value}');
late final void Function() _dispose;

@override
void initState() {
super.initState();
_dispose = effect(() => fullName.value);
}

2、使用watch更新UI


Text(
fullName.watch(context),
style: Theme.of(context).textTheme.headlineMedium,
),

作者:icc_tips
来源:juejin.cn/post/7309131109740724259
收起阅读 »

拥抱华为,困难重重,第一天开始学习 ArkUI,踩坑踩了一天

今天第一天正式开始学习鸿蒙应用开发。 本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 Ark...
继续阅读 »

今天第一天正式开始学习鸿蒙应用开发。


本来想着,作为一个拥有 10 来年工作经验的资深开发,对 React/Vue,React Native,Android 开发都有丰富的实践经验,对 Swift UI 也有所涉猎,在大前端这一块可以说是信心满满,学习 ArkUI 应该信手拈来才对,谁知道学习的第一天,我就发现我太天真了。


HarmonyOS 与 ArkUI 给我了沉痛一击


学习第一天一点都不顺利,上午还算有所收获,下午直接毫无建树,踩在一个坑里出不来,人直接裂开,差点以为自己要创业未半而中道崩殂了。不过好在晚饭后,侥幸解决了下午遇到的坑


最终今天学习的成果如下


scroll.gif


导航栏的4个图标都是用了 lottie 的动画,因为使用了 gif 录制,可能有点感觉不太明显,真机上的感受非常舒适,用户体验极佳


今天已经学习过的内容包括



  • 基础项目结构

  • 基础布局组件

  • 容器布局组件

  • 滚动组件

  • 导航组件

  • ohpm 安装

  • 引入 lottie 动画

  • 属性动画

  • 配置 hot reload

  • 组件状态管理 @state @props @link

  • 组件逻辑表达式

  • 沉浸式状态栏

  • 真机调试


我的开发设备具体情况如下


MacOS M1
HarmonyOS API 9
华为 P40 pro+,已安装 HarmonyOS 4

作为一个把主要精力放在前端的开发者,做个记录分享一下学习体会


01


组件概念


在前端开发中,不管你是用 React 还是使用 Vue,我们只需要掌握一个概念:组件。复杂的组件是由小的组件组成,页面是由组件组成,项目是由组件组成,超大项目也是由组件组成。组件可以组成一切。因此 React/Vue 的学习会相对更简单一些


和 Android 一样,由于 HarmonyOS 有更复杂的应用场景、多端、分屏等,因此在这一块的概念也更多一些,目前我接触到的几个概念包括


Window 一个项目好像可以有多个窗口,由于接触的时间太短了暂时不是很确定,可以创建子窗口,可以管理窗口的相关属性,创建,销毁等


Ability 用户与应用交互的入口点,一个 app 可以有一个或者对个 Ability


page 页面,一个应用可以由多个 page 组成


Component 组件,可以组合成页面


由于目前接触的内容不够全面,因此对这几个概念的理解还不够笃定,只是根据自己以往的开发经验推测大概可能是什么情况,因此介绍得比较简单,但是可以肯定的是理解这些概念是必备的


02


基础布局


虽然 HarmonyOS 目前也支持 web 那一套逻辑开发,不过官方文档已经明确表示未来将会主推 arkUI,因此我个人觉得还是应该把主要学习重心放在 arkUI 上来


arkUI 的布局思路跟 html + css 有很大不同。


html + css 采用的是结构样式分离的方式,再通过 class/id 关联起来。因此,html + css 的布局写起来会简单很多,我们只需要先写结构,然后慢慢补充样式即可


arkUI 并未采用分离思路,而是把样式和结构紧密结合在一起,这样做的好处就是性能更强了,因为底层渲染引擎不用专门写一套逻辑去匹配结构和样式然后重新计算 render-tree,坏处就是...


代码看着有点糟心


比如下面这行代码,表示两段文字


Column() {
Text('一行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
Text('二行文字')
.textAlign(TextAlign.Center)
.fontSize(30)
.width('100%')
.backgroundColor('#aabbcc')
}.width('100%')
.height('100%')
.backgroundColor('red')

如果用 html 来表示的话....


<div>
<p>一行文字p>
<p>一行文字p>
div>

当然我期望能找到一种方式去支持属性的继承和复用。目前简单找了一下没找到,希望有吧 ~


由于 html 中 div 足以应付一切,因此许多前端开发者会在思考过程中忽视或者弱化容器组件的存在,反而 arkUI 的学习偏偏要从容器组件开始理解


我觉得这种思路会对解耦思路有更明确的训练。许多前端开发在布局时不去思考解耦问题,我认为这是一个坏处。


arkUI 的布局思路是:先考虑容器,再考虑子元素,并且要把样式特性结合起来一起思考。而不是只先思考结构,再考虑样式应该怎么写。


例如,上面的 GIF 图中, nav 导航区域是由 4 按钮组成。先考虑容器得是一个横向的布局


然后每一个按钮,包括一个图标和一个文字,他们是纵向的布局,于是结构就应该这样写


Row: 横向布局
Column: 竖向布局
Row() {
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
Column() { Lottie() Text() }
}

按照这个思路去学习,几个容器组件 Row/Column/FLex/Stack/GridContainer/SideBarContainer ... 很快就能掌握


03


引入 lottie


在引入 lottie 的时候遇到了几个坑。


一个是有一篇最容易找到的文章介绍如何在 arkUI 中引入 lottie,结果这篇文章是错误的。 ~ ~,这篇文章是在官方博客里首发,让我走了不少弯路。


image.png


这里面有两个坑,一个坑是 @ohos/lottie-ohos-ets 的好像库不见了。另外一个坑就是文章里指引我用 npm 下载这个库。但是当我用 npm 下载之后,文件会跑到项目中的 node_modules 目录下,不过如何在 arkUI 的项目中引入 node_modules 中的库,我还没找到方法,应该是要在哪里配置一下


最后在 gitee 的三方仓库里,找到了如下三方库


import lottie from '@ohos/lottie';

这里遇到的一个坑就是我的电脑上的环境变量不知道咋回事被改了,导致 ohpm 没了,找了半天才找到原因,又重新安装 ohpm,然后把环境变量改回来



  1. 到官方文档下载对应的工具包

  2. 把工具包放到你想要放的安装目录,然后解压,进去 ohpm/bin 目录,在该目录下执行 init 脚本开始安装


> init


  1. 然后使用如下指令查看当前文件路径


> pwd

然后执行如下指令


// OHPM_HOME 指的是你自己的安装路径
> export OHPM_HOME=/home/xx/Downloads/ohpm
> export PATH=${OHPM_HOME}/bin:${PATH}


  1. 执行如下指令检查是否安装成功


> ohpm -v

@ohos/lottie


使用如下指令下载 lottie


ohpm install @ohos/lottie

然后在 page 中引入


import lottie from '@ohos/lottie'

在类中通过定义私有变量的方式构建上下文


private mrs: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mrs)

并且用私有变量保存 lottie 数据路径或者内容


private path: string = 'common/lottie/home.json'

然后在 build 中,结合 Canvas 组件绘制


Canvas(this.ctx).onReady(() => {
lottie.loadAnimation({
container: this.ctx,
renderer: 'canvas',
loop: false,
autoplay: true,
path: this.path
})
})

参考文章:@ohos/lottie


04


hot reload


使用 commond + , 调出配置页面,然后通过如下路径找到配置选中 Perform hot reload


Tools -> Actions on Save -> Perform hot reload

image.png


然后在项目运行的入口处,选择 entry -> edit configrations,弹出如下界面,选中 Hot Reload 的 entry,做好与下图中一致的勾选,点击 apply 按钮之后启动项目即可实现 hot reload


image.png


不过呢,hot reload 在调试样式的时候还能勉强用一用,涉及到代码逻辑的更改,往往没什么用,属实是食之无味,弃之可惜


除此之外,也许 Previewer 更适合开发时使用


image.png


05


沉浸式状态栏


沉浸式状态栏是一款体验良好的 app 必备能力。因此我学会了基础知识之后,第一时间就想要研究一下再 HarmonyOS 中如何达到这个目的。


沉浸式状态栏指的就是下图中位置能够做到与页面标题栏,或者页面背景一致的样式。或者简单来说,可以由我们开发者来控制这一块样式。布局进入全屏模式。


image.png


在我们创建入口 Ability 时,可以在生命周期 onWindowStageCreate 中设置全屏模式


onWindowStageCreate(windowStage: window.WindowStage) {
windowStage.getMainWindow(err, mainWindow: window.Window) {
if (err.code) {
return
}
mainWindow.setWindowLayoutFullScreen(true)
}
}

setWindowLayoutFullScreen 是一个异步函数,因此如果你想要修改状态栏样式的话,可以在它的回调里,通过 setWindowSystemBarProperties 去设置


mainWindow.setWindowLayoutFullScreen(true, (err) => {
if (err) { return }
mainWindow.setWindowSystemBarProperties({ statusBarColor: '#FFF' })
})

具体的参数配置,可以在代码中,查看类型声明获悉。


这里有一个巨大的坑,就是在我的开发环境启动的模拟器中 API 9,当你设置了全屏模式之后,布局会发生混乱。真机调试又是正常的。


我刚开始以为是我的代码哪里没搞对,为了解决这个问题花了一个多小时的时间,结果最后才确定是模拟器的布局 bug...


真机调试


真机调试的设置方式主要跟其他 app 开发都一样,把手机设置为开发者模式即可。不过你需要通过如下方式,配置好一个应用签名才可以。因此你首先需要注册成为华为开发者


File -> Project Structure -> Signing Configs -> Sign in

跟着指引在后台创建项目,然后再回到开发者工具这个页面自动生成签名即可


image.png


真机调试有一个巨大无比的坑,那就是 API 9 创建的项目,在老版本的麒麟芯片上巨卡无比。连基本的点击都无法响应。


这就要了命了。如果连真机调试都做不到,那还拥抱个啥啊?


研究了很久,找到了几个解决这个问题的方法


1、换新机,只要你的手机不是华为被制裁之前的麒麟芯片,都不会存在这个问题


2、创建项目时,选择 API 8


3、在开发者选项的配置中,选择 显示面(surface)更新,虽然不卡了,不过闪瞎了我的狗眼


4、等明年 HarmonyOS next 出来之后再来学,官方说,API 10 将会解决这个问题


上面的解决办法或多或少都有一些坑点。我选择了一种方式可以很好的解决这个问题


那就是:投屏


如果你有一台华为电脑,这个投屏会非常简单。不过由于我是 mac M1,因此我选择的投屏方案是 scrcpy


使用 brew 安装


> brew install scrcpy

然后继续安装


> brew install android-platform-tools

启动


> scrcpy

启动之前确保只有一台手机已经通过 USB 连接到电脑,并允许电脑调试手机就可以成功投屏。在投屏中操作手机,就变得非常流畅了


不过目前我通过这种方式投屏之后,运行起来的项目经常闪退,具体是什么原因我还没找到,只能先忍了


总之就是坑是一个接一个 ~ ~


06


总结


一整天的学习,整体感受下就如标题说的那样:拥抱华为,困难重重。 还好我电脑性能强悍,要是内存少一点,又是虚拟机,又是投屏的,搞不好内存都不够用,可以预想,其他开发者还会遇到比我更多的坑 ~ ~


image.png


个人感觉华为相关的官方文档写得不是很友好,比较混乱,找资料很困难。反而在官方上把一堆莫名其妙的教学视频放在了最重要的位置,我不是很明白,到底是官方文档,还是视频教程网站 ~ ~


官方文档里还涉及了 FA mode 到 Stage mode 的更新,因此通过搜索引擎经常找到 FA mode 的相关内容,可是 FA mode 又是被弃用的,因为这个问题也给我的学习带来了不少的麻烦。由于遇到的坑太多了,以致于我到现在尝试点什么新东西都紧张兮兮的,生怕又是坑


总的来说,自学困难重重,扛得住坑的,才能成为最后的赢家,红利不是那么好吃的


作者:这波能反杀
来源:juejin.cn/post/7309734518586523657
收起阅读 »

WebSocket 从入门到入土

web
前言因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!一.WebSocket 基本概念1.W...
继续阅读 »

前言

因新部门需求有一个后台管理需要一个右上角的实时的消息提醒功能,第一时间想到的就是使用WebSocket建立实时通信了,之前没整过,于是只能学习了。和原部门相比现在太忙了,快乐的日子一去不复返了。经典的加量不加薪啊!!!

一.WebSocket 基本概念

1.WebSocket是什么?

WebSocket 是基于 TCP 的一种新的应用层网络协议。它提供了一个全双工的通道,允许服务器和客户端之间实时双向通信。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。 WebSocket

2.与 HTTP 协议的区别

与 HTTP 协议相比,WebSocket 具有以下优点:

  1. 更高的实时性能:WebSocket 允许服务器和客户端之间实时双向通信,从而提高了实时通信场景中的性能。
  2. 更少的网络开销:HTTP 请求和响应之间需要额外的数据传输,而 WebSocket 通过在同一个连接上双向通信,减少了网络开销。
  3. 更灵活的通信方式:HTTP 请求和响应通常是一一对应的,而 WebSocket 允许服务器和客户端之间以多种方式进行通信,例如消息 Push、事件推送等。
  4. 更简洁的 API:WebSocket 提供了简洁的 API,使得客户端开发人员可以更轻松地进行实时通信。

当然肯定有缺点的:

  1. 不支持无连接: WebSocket 是一种持久化的协议,这意味着连接不会在一次请求之后立即断开。这是有利的,因为它消除了建立连接的开销,但是也可能导致一些资源泄漏的问题。
  2. 不支持广泛: WebSocket 是 HTML5 中的一种标准协议,虽然现代浏览器都支持,但是一些旧的浏览器可能不支持 WebSocket。
  3. 需要特殊的服务器支持: WebSocket 需要服务端支持,只有特定的服务器才能够实现 WebSocket 协议。这可能会增加系统的复杂性和部署的难度。
  4. 数据流不兼容: WebSocket 的数据流格式与 HTTP 不同,这意味着在不同的网络环境下,WebSocket 的表现可能会有所不同。

3.WebSocket工作原理

1. 握手阶段

WebSocket在建立连接时需要进行握手阶段。握手阶段包括以下几个步骤:

  • 客户端向服务端发送请求,请求建立WebSocket连接。请求中包含一个Sec-WebSocket-Key参数,用于生成WebSocket的随机密钥。
  • 服务端接收到请求后,生成一个随机密钥,并使用随机密钥生成一个新的Sec-WebSocket-Accept参数。
  • 客户端接收到服务端发送的新的Sec-WebSocket-Accept参数后,使用原来的随机密钥和新的Sec-WebSocket-Accept参数共同生成一个新的Sec-WebSocket-Key参数,用于加密数据传输。
  • 客户端将新的Sec-WebSocket-Key参数发送给服务端,服务端接收到后,使用该参数加密数据传输。

2. 数据传输阶段

建立连接后,客户端和服务端就可以通过WebSocket进行实时双向通信。数据传输阶段包括以下几个步骤:

  • 客户端向服务端发送数据,服务端收到数据后将其转发给其他客户端。
  • 服务端向客户端发送数据,客户端收到数据后进行处理。

双方如何进行相互传输数据的 具体的数据格式是怎么样的呢?WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。

发送方 -> 接收方:ping。

接收方 -> 发送方:pong。

ping 、pong 的操作,对应的是 WebSocket 的两个控制帧

3. 关闭阶段

当不再需要WebSocket连接时,需要进行关闭阶段。关闭阶段包括以下几个步骤:

  • 客户端向服务端发送关闭请求,请求中包含一个WebSocket的随机密钥。
  • 服务端接收到关闭请求后,向客户端发送关闭响应,关闭响应中包含服务端生成的随机密钥。
  • 客户端收到关闭响应后,关闭WebSocket连接。

总的来说,WebSocket通过握手阶段、数据传输阶段和关闭阶段实现了服务器和客户端之间的实时双向通信。

二.WebSocket 数据帧结构和控制帧结构。

1. 数据帧结构

WebSocket 数据帧主要包括两个部分:帧头和有效载荷。以下是 WebSocket 数据帧结构的简要介绍:

  • 帧头:帧头包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,fin 表示数据帧的结束标志,rsv1、rsv2、rsv3 表示保留字段,opcode 表示数据帧的类型,masked 表示是否进行掩码处理,payload_length 表示有效载荷的长度。
  • 有效载荷:有效载荷是数据帧中实际的数据部分,它由客户端和服务端进行数据传输。

2. 控制帧结构

除了数据帧之外,WebSocket 协议还包括一些控制帧,主要包括 Ping、Pong 和 Close 帧。以下是 WebSocket 控制帧结构的简要介绍:

  • Ping 帧:Ping 帧用于测试客户端和服务端之间的连接状态,客户端向服务端发送 Ping 帧,服务端收到后需要向客户端发送 Pong 帧进行响应。
  • Pong 帧:Pong 帧用于响应客户端的 Ping 帧,它用于测试客户端和服务端之间的连接状态。
  • Close 帧:Close 帧用于关闭客户端和服务端之间的连接,它包括四个部分:fin、rsv1、rsv2、rsv3、opcode、masked 和 payload_length。其中,opcode 的值为 8,表示 Close 帧。

三. JavaScript 中 WebSocket 对象的属性和方法,以及如何创建和连接 WebSocket。

WebSocket 对象的属性和方法:

  1. WebSocket 对象:WebSocket 对象表示一个新的 WebSocket 连接。
  2. WebSocket.onopen 事件处理程序:当 WebSocket 连接打开时触发。
  3. WebSocket.onmessage 事件处理程序:当接收到来自 WebSocket 的消息时触发。
  4. WebSocket.onerror 事件处理程序:当 WebSocket 发生错误时触发。
  5. WebSocket.onclose 事件处理程序:当 WebSocket 连接关闭时触发。
  6. WebSocket.send 方法:向 WebSocket 发送数据。
  7. WebSocket.close 方法:关闭 WebSocket 连接。

创建和连接 WebSocket:

  1. 创建 WebSocket 对象:
var socket = new WebSocket('ws://example.com');

其中,ws://example.com 是 WebSocket 的 URL,表示要连接的服务器。

  1. 连接 WebSocket:

使用 WebSocket.onopen 事件处理程序检查 WebSocket 是否成功连接。

socket.onopen = function() {
console.log('WebSocket connected');
};
  1. 接收来自 WebSocket 的消息:

使用 WebSocket.onmessage 事件处理程序接收来自 WebSocket 的消息。

socket.onmessage = function(event) {
console.log('WebSocket message:', event.data);
};
  1. 向 WebSocket 发送消息:

使用 WebSocket.send 方法向 WebSocket 发送消息。

socket.send('Hello, WebSocket!');
  1. 关闭 WebSocket:

当需要关闭 WebSocket 时,使用 WebSocket.close 方法。

socket.close();

注意:在 WebSocket 连接成功打开和关闭时,会分别触发 WebSocket.onopen 和 WebSocket.onclose 事件。在接收到来自 WebSocket 的消息时,会触发 WebSocket.onmessage 事件。当 WebSocket 发生错误时,会触发 WebSocket.onerror 事件。

四.webSocket简单示例

以下是一个简单的 WebSocket 编程示例,通过 WebSocket 向服务器发送数据,并接收服务器返回的数据:

  1. 首先,创建一个 HTML 文件,添加一个按钮和一个用于显示消息的文本框:
html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket 示例title>
head>
<body>
<button id="sendBtn">发送消息button>
<textarea id="messageBox" readonly>textarea>
<script src="main.js">script>
body>
html>
  1. 接下来,创建一个 JavaScript 文件(例如 main.js),并在其中编写以下代码:
// 获取按钮和文本框元素
const sendBtn = document.getElementById('sendBtn');
const messageBox = document.getElementById('messageBox');

// 创建 WebSocket 对象
const socket = new WebSocket('ws://echo.websocket.org'); // 使用一个 WebSocket 服务器进行测试

// 设置 WebSocket 连接打开时的回调函数
socket.onopen = function() {
console.log('WebSocket 连接已打开');
};

// 设置 WebSocket 接收到消息时的回调函数
socket.onmessage = function(event) {
console.log('WebSocket 接收到消息:', event.data);
messageBox.value += event.data + '\n';
};

// 设置 WebSocket 发生错误时的回调函数
socket.onerror = function() {
console.log('WebSocket 发生错误');
};

// 设置 WebSocket 连接关闭时的回调函数
socket.onclose = function() {
console.log('WebSocket 连接已关闭');
};

// 点击按钮时发送消息
sendBtn.onclick = function() {
const message = 'Hello, WebSocket!';
socket.send(message);
messageBox.value += '发送消息: ' + message + '\n';
};

五.webSocket应用场景

  1. 实时通信:WebSocket 非常适合实时通信场景,例如聊天室、在线游戏、实时数据传输等。通过 WebSocket,客户端和服务器之间可以实时通信,无需依赖轮询,从而提高通信效率和减少网络延迟。
  2. 监控数据传输:WebSocket 可以在监控系统中实现实时数据传输,例如通过 WebSocket,客户端可以实时接收和处理监控数据,而无需等待轮询数据。
  3. 自动化控制:WebSocket 可以在自动化系统中实现远程控制,例如通过 WebSocket,客户端可以远程控制设备或系统,而无需直接操作。
  4. 数据分析:WebSocket 可以在数据分析场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据存储和分析。
  5. 人工智能:WebSocket 可以在人工智能场景中实现实时数据传输和处理,例如通过 WebSocket,客户端可以实时接收和处理数据,而无需等待数据处理和分析。

六.WebSocket 错误处理

WebSocket 的错误处理

  1. WebSocket is not supported:当浏览器不支持 WebSocket 时,会出现此错误。解决方法是在浏览器兼容性列表中检查是否支持 WebSocket。
  2. WebSocket connection closed:当 WebSocket 连接被关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  3. WebSocket error:当 WebSocket 发生错误时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  4. WebSocket timeout:当 WebSocket 连接超时时,会出现此错误。解决方法是在 WebSocket.ontimeout 事件处理程序中进行错误处理。
  5. WebSocket handshake error:当 WebSocket 握手失败时,会出现此错误。解决方法是在 WebSocket.onerror 事件处理程序中进行错误处理。
  6. WebSocket closed by server:当 WebSocket 连接被服务器关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  7. WebSocket closed by protocol:当 WebSocket 连接被协议错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  8. WebSocket closed by network:当 WebSocket 连接被网络错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。
  9. WebSocket closed by server:当 WebSocket 连接被服务器错误关闭时,会出现此错误。解决方法是在 WebSocket.onclose 事件处理程序中进行错误处理。

通过为 WebSocket 对象的 oncloseonerror 和 ontimeout 事件添加处理程序,可以及时捕获和处理 WebSocket 错误,从而确保程序的稳定性和可靠性。

七.利用单例模式创建完整的wesocket连接

class webSocketClass {
constructor(thatVue) {
this.lockReconnect = false;
this.localUrl = process.env.NODE_ENV === 'production' ? 你的websocket生产地址' : '测试地址';
this.globalCallback = null;
this.userClose = false;
this.createWebSocket();
this.webSocketState = false
this.thatVue = thatVue
}

createWebSocket() {
let that = this;
// console.log('
开始创建websocket新的实例', new Date().toLocaleString())
if( typeof(WebSocket) != "function" ) {
alert("您的浏览器不支持Websocket通信协议,请更换浏览器为Chrome或者Firefox再次使用!")
}
try {
that.ws = new WebSocket(that.localUrl);
that.initEventHandle();
that.startHeartBeat()
} catch (e) {
that.reconnect();
}
}

//初始化
initEventHandle() {
let that = this;
// //连接成功建立后响应
that.ws.onopen = function() {
console.log("连接成功");
};
//连接关闭后响应
that.ws.onclose = function() {
// console.log('
websocket连接断开', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onerror = function() {
// console.log('
websocket连接发生错误', new Date().toLocaleString())
if (!that.userClose) {
that.reconnect(); //重连
}
};
that.ws.onmessage = function(event) {
that.getWebSocketMsg(that.globalCallback);
// console.log('
socket server return '+ event.data);
};
}
startHeartBeat () {
// console.log('
心跳开始建立', new Date().toLocaleString())
setTimeout(() => {
let params = {
request: '
ping',
}
this.webSocketSendMsg(JSON.stringify(params))
this.waitingServer()
}, 30000)
}
//延时等待服务端响应,通过webSocketState判断是否连线成功
waitingServer () {
this.webSocketState = false//在线状态
setTimeout(() => {
if(this.webSocketState) {
this.startHeartBeat()
return
}
// console.log('
心跳无响应,已断线', new Date().toLocaleString())
try {
this.closeSocket()
} catch(e) {
console.log('
连接已关闭,无需关闭', new Date().toLocaleString())
}
this.reconnect()
//重连操作
}, 5000)
}
reconnect() {
let that = this;
if (that.lockReconnect) return;
that.lockReconnect = true; //没连接上会一直重连,设置延迟避免请求过多
setTimeout(function() {
that.createWebSocket();
that.thatVue.openSuccess(that) //重连之后做一些事情
that.thatVue.getSocketMsg(that)
that.lockReconnect = false;
}, 15000);
}

webSocketSendMsg(msg) {
this.ws.send(msg);
}

getWebSocketMsg(callback) {
this.ws.onmessage = ev => {
callback && callback(ev);
};
}
onopenSuccess(callback) {
this.ws.onopen = () => {
// console.log("连接成功", new Date().toLocaleString())
callback && callback()
}
}
closeSocket() {
let that = this;
if (that.ws) {
that.userClose = true;
that.ws.close();
}
}
}
export default webSocketClass;

作者:耀耀切克闹灬
来源:juejin.cn/post/7309687967063818292

收起阅读 »

按钮点击的水波效果

web
实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。 HTML 结构比较简单,我们用 div 来表示 button...
继续阅读 »

image


实现思路:水波效果可以用一个 span 来模拟,动画效果是缩放从 0 到大于零的值(比如 4),同时透明度从 1 到 0。点击 button 后,我们把这个 span 添加到 button 里即可。


HTML


结构比较简单,我们用 div 来表示 button:


<div class="button">
Click Me
</div>

CSS


给 div 加点样式,让它看起来像个 button:


image


.button {
margin-left: 100px;
position: relative;
width: 100px;
padding: 8px 10px;
border: 1px solid lightgray;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
user-select: none;
}

定义水波样式,默认 scale 为 0:


.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple 600ms linear;
background-color: rgba(30, 184, 245, 0.7);
}

水波动画:


@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}

javascript


点击按钮时,生成水波效果,先把结构加上:


function playRipple(event) {
// TODO:生成水波效果
}

// 为 button 添加点击事件
document
.querySelector('.button')
.addEventListener('click', event => {
playRipple(event);
})

我们看一下水波如何生成,为了方便理解,可以结合图来看,其中黑点表示鼠标点击的位置,蓝色的圆是点击后水波默认大小的圆,** ?**就表示要计算的 circle.style.left:


image


function playRipple(event) {
const button = event.currentTarget;
const buttonRect = button.getBoundingClientRect();

const circle = document.createElement("span");
// 圆的直径
const diameter = Math.max(button.clientWidth, button.clientHeight);
// 圆的半径
const radius = diameter / 2;

// 计算 ripple 的位置
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.clientX - (buttonRect.left + radius)}px`;
circle.style.top = `${event.clientY - (buttonRect.top + radius)}px`;
// 添加 ripple 样式
circle.classList.add("ripple");
// 移除已存在的 ripple
removeRipple(button);
// 将 ripple 添加到 button 上
button.appendChild(circle);
}

// 移除 ripple
function removeRipple(button) {
const ripple = button.querySelector(".ripple");

if (ripple) {
ripple.remove();
}
}

看下效果:
image


总结


又水了一篇文章😂,如果对你有启发,欢迎点赞、评论。


参考


css-tricks.com/how-to-recr…


作者:探险家火焱
来源:juejin.cn/post/7224063449617383485
收起阅读 »

如何做好前端项目组组长

前言 唠嗑 俺自己弄自己写博客是为了记录自己的脚步,走成功就留下近道,方便其他兴趣者抄近道提升;走失败了就留下血迹(魂类游戏の特色),方便其他人看看我是这么寄的。 我曾经给自己规定,一个月最少留下一片技术性的或者经验性值的博客,方便自己自我总结。结果十月底后,...
继续阅读 »



前言 唠嗑


俺自己弄自己写博客是为了记录自己的脚步,走成功就留下近道,方便其他兴趣者抄近道提升;走失败了就留下血迹(魂类游戏の特色),方便其他人看看我是这么寄的。


我曾经给自己规定,一个月最少留下一片技术性的或者经验性值的博客,方便自己自我总结。结果十月底后,咱忙得不可开交~~,都没时间水群~~,写博客的规划就一拖再拖,最后都十二月了,emmmm,不能再拖了。今天就写完。
6号的今儿,加个班,努力写完吧


一、个人方面


角色转变


以前是组员,会追求极致的代码逻辑或写出最优性能的算法。但现在你是组长了,你得学会接纳不完美,比如每次mr的时候不能太过计较组员代码性能或者代码逻辑(个人经验,可能不用于大厂)。


其二,在团队中,平常心非常重要。无论是组长还是组员,大家都是打工人,没有高人一等的态度。


学习方向


学习方向要从原来的学得深改为看得广。这样方便给组员提供解决问题思路或者功能实现方案。



当组员的时候我会专研得很深,甚至会深入专研vue2底层代码甚至去自己手写一个自己的vue2 demo。


当组长后,我很少专研底层代码或者底层架构了,大多都是看其他作者如何解决没见过的业务的问题,亦或者是使用某个依赖出现的模块出现问题以及避免方法。积累新模块使用以及新的业务解决方案。



a620e57a0ae162f0e4aa34bb1d4d8ecb5ce17e72eede4e8d024a8d68d3859602.png


二、组内安排


统筹和分配


产品给的需求、后端配合人员、bug转交等等,这些都归属于任务类型,要记得如何分配任务以及实时跟踪进度(按天跟踪最好)。


Weixin Screenshot_20231130222156.png


分配任务时候请注意:



  • 产品需求方面一定要记住划分模块,再记住模块对应的组员,方便后续QA多轮轮测试时候bug指向对应的组员,亦或者编写《XXXX技术规格书》时将其划分给对应组员

  • 对每个任务划分好难度,根据组员能力差异给到最优解


学会做自己组的产品(建议)


注意,这个只是建议,不是必须!


前端组长也要会当产品?是,也不是。比如说在项目立项前期,有些东西必须前端自己规划好,如框架搭建指南、二次封装的公共组件(如搜索表单,公共列表,echarts的各类型图表),这个时候就需要你自己做自己的产品经理,自己写相关的需求文档或者技术规格文档。


可以不写么?如果你能让组员明白你的规划或者明白你的思路,你可以不用写,只需要交代就行。否则还是建议写一下。


提供一定的情绪价值


这个只可意会不可言传的,需要自己把握好度,平衡好自己的情绪以及组员的情绪。


7176b207911683222628d044b6fdf104cccacda7bc9c0f98646bc80d0d30a894.png


三、项目组角色


前端组长还是前端开发,所以说本职前端工作要有,还得担当一些其他任务。


做好项目组副手


虽然是前端组长,虽然入手的是js、ts、node,但你还是要了解一些其他与前端开发或者与项目组相关的东西,这里是我经历过的一些事儿,可以借鉴一下:



  • 学一些基础的PS平面设计概念,便于和UI统一意见

  • linux 虚机,需要本地VMware或者公司服务器

  • CI/CD 流程

  • docker 配置文件、基础指令

  • nginx 设置反向代理

  • shell 脚本编写

  • 手写case,方便开发自测

  • 了解公司发布流程,准备好补充缺失的文件

  • 学会公司文件管理方式,如SVN、企业级Visual Studio


与UI配合


以下是我根据个人经验总结的一些建议:



  • 组长层面

    • 确认公共组件统一样式

      • 公共列表样式

      • 搜索表单样式

      • Dialog/Modal对话框 宽度和最大高度以及高度是否固定

      • Description 统一样式

      • 滚动条样式(ChromeFirefox)

      • Button/Tag 边框弧度

      • Layout框架样式,如菜单padding距离、

      • 文本/内容超出部分处理方案

      • 图片使用格式 png/svg

      • Notification通知框出现位置、按钮、存在时间

      • 统一图表获取方式,如提供手动图表库或者使用三方图表库



    • 参与设计图评审

      • 创建编辑操作时注意其标注必填项以及对应选项框是否一致

      • 首页/门户页面/欢迎页面/列表 处理文本过长,内容过多的方案

      • 交互/大屏 动画效果确认





  • 开发层面

    • 学会自己切图,如使用国内的'蓝湖','即使设计',亦或者是adobe的XD

    • 让UI帮忙修图时候尽量让UI用上SVG图片

      • SVG是矢量图,可以提供图层信息,方便UI调整



    • 如果涉及动画效果之类的(如告警闪烁效果),可以给UI写个可调整页面,让UI自己寻找合适的感觉




与产品配合


以下是我根据个人经验总结的一些建议:



  • 组长层面:

    • 需求评审时

      • 建议记录每个具体的模块以及其大概功能点(比如创建,编辑,删除这类操作性的,如果详情里也有的话同步记录),方便后续分配任务以及自测时写case

      • 这个算是空话,但还是记下来吧:仔细听产品报告,确认功能可行性



    • 帮产品搭建原型图服务,方便UI和自己组员查阅



  • 开发层面

    • 功能时间过于耗时并且不是主要功能时,及时告诉产品,协商解决方案

    • 集成系统并且无法从三方系统/三方厂商获取数据或者是,必须及时告诉产品




与后端配合


唯一一个跟咱一样是开发的,懂逻辑的童鞋们~~,感觉我可以偷个懒不写建议~~,还是要写一下建议:



  • 组长层面:

    • 及时告知后端童鞋配合一起开发的前端童鞋

    • 协助后端更新服务器上的容器,或者帮其完善CI/CD




eed8adb174843fb8e32281a925c8d392955e1ce405eaf0bb132f42fab52e1364.png


尾声


如果不嫌弃,请大佬们在评论区教我做人。


9efa601e7dcfa58e1135bde96bd2a83fb3d3c33acf2bc376272a2c7e749a2740.png


作者:望远镜
来源:juejin.cn/post/7309301549154779171
收起阅读 »

“浏览器切换到其他页面或最小化时,倒计时不准确“问题解析

web
背景 我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。 倒计时大概逻辑如下: const leftTime = 600; //单位为秒 const timer = ...
继续阅读 »

背景


我最近修复了一个倒计时延迟的bug,情况是用户10:00设置了10分钟倒计时,10:06查看时发现倒计时还有8分钟,倒计时出不准确、延迟的情况。


倒计时大概逻辑如下:


const leftTime = 600; //单位为秒
const timer = setInterval(() => {
leftTime -= 1;
if(leftTime === 0) {
clearInterval(timer);
}
}, 1000);

通过排查是浏览器的优化策略导致的。


为什么浏览器优化策略会造成定时器不准时?又该怎么解决这个问题?本文会围绕这两个问题展开说明!


浏览器优化策略对定时器的影响


浏览器的优化策略是指浏览器为了提高性能和节省资源而对特定任务进行的优化。在后台标签页中,浏览器可能会对一些任务进行节流或延迟执行,以减少CPU和电池的消耗。


而定时器setIntervalsetTimeout就是受浏览器优化策略的影响,导致定时器的执行时间间隔被延长。所以在浏览器切换到其他页面或者最小化时,当前页面的定时器可能不会按照预期的时间间隔准时执行。


我们实验一下:设置一个定时器,每500ms在控制台输出当前时间;然后再监听该标签页的visibilitychange事件,当其选项卡的内容变得可见或被隐藏时,会触发该事件。


// 设置定时器
const leftTime = 600; // 倒计时剩余时间
setInterval(() => {
const date = new Date();
leftTime.value -= 1;
console.log(`倒计时剩余秒数:${ leftTime.value }`, `当前时间秒数:${ date.getSeconds() }`);
}, 1000);
// 通过监听 visibilitychange 事件来判别该页面是否可见
document.addEventListener('visibilitychange', function () {
if(document.hidden) {
console.log('页面不可见')
}
})

执行结果如下:


image.png


我们观察执行结果会发现,在标签页处于不可见状态后,setInterval从1000ms的时间间隔延长成了2000ms。


由此可见,当浏览器切换其他页面或者最小化时,倒计时的误差就出现了,setInterval定时器也不会在1000ms后减去1。对于时间较长的倒计时来说,误差会更大。


解决思路


既然浏览器的定时器有问题,那我们就不依赖定时器去计算剩余时间。


我们可以在用户配置倒计时后,立即计算出结束时间并保存,随后通过结束时间减去本地时间就得出了剩余时间,而且不会受定时器延迟的影响。将最上面提及到的倒计时伪代码修改如下:


// ......
const leftTime = 600 * 1000
const endTime = Date.now() + leftTime; // 倒计时结束时间
setInterval(() => {
const date = new Date();
leftTime = Math.round((endTime - Date.now()) / 1000);
console.log(`倒计时剩余秒数:${ leftTime }`, `当前时间秒数:${ date.getSeconds() }`);
if(leftTime <= 0) {
clearInterval(timer);
}
}, 1000)

根据以上代码进行计算,即使标签页不处于可见状态,setInterval延迟执行,对leftTime也没有影响。
执行结果如下(标签页处于不可见状态时):
image.png


题外话


用 setTimeout 实现 setInterval


实现思路是setTimeout的递归调用。以上面的举例代码为例作修改:


const leftTime = 600 * 1000;
const endTime = Date.now() + leftTime; // 倒计时结束时间
function setTimer() {
leftTime = Math.round((endTime - Date.now()) / 1000);
if ( leftTime <= 0 ) {
endTime = 0;
leftTime = 0;
} else {
setTimeout(setTimer, 1000);
}
}

本次分享就到这,希望可以帮助到有同样困扰的小伙伴哦~


作者:Swance
来源:juejin.cn/post/7309693162369171507
收起阅读 »

初中都没念完的我,是怎么从IT这行坚持下去的...

大家好,我是一名二线(伪三线,毕竟连续两年二线城市了)的程序员。 现阶段状态在职,28岁,工作了10年左右,码农从事了5年左右,现薪资9k左右。如文章标题所说,初二辍学,第一学历中专,自己报的成人大专。 在掘金也看了不少经历性质的文章,大多都是很多大牛的文章,...
继续阅读 »

大家好,我是一名二线(伪三线,毕竟连续两年二线城市了)的程序员。


现阶段状态在职,28岁,工作了10年左右,码农从事了5年左右,现薪资9k左右。如文章标题所说,初二辍学,第一学历中专,自己报的成人大专。


在掘金也看了不少经历性质的文章,大多都是很多大牛的文章,在大城市的焦虑,在大厂的烦恼,所以今天换换口味,看一看我这个没有学历的二线的程序员的经历。


1.jpg


1.辍学


我是在初二的时候辍学不上的,原因很简单,太二笔了。


现在想来当时的我非常的der,刚从村里的小学出来上中学之后(我还是年级第7名进中学,殊不知这就是我这辈子最好的成绩了),认为别人欺负我我就一定要还回来,完全不知道那是别人的地盘,嚣张的一批,不出意外就被锤了,但是当时个人武力还是很充沛的,按着一个往地上锤,1V7的战绩也算可以了。自此之后,我就开始走上了不良的道路,抽烟喝酒打架,直到中专毕业那天。



我清楚的记得我推着电车望着天,心里只想着一个问题,我毕业了,要工作了,我除了打游戏还会什么呢,我要拿什么生存呢...



这是当时我心里真实的想法,我好像就在这一刻、这一瞬间长大了。


2.jpg


2.深圳之旅


因为我特别喜欢玩游戏,而且家里电脑总是出问题,所以我就来到了我们这当地的一个电脑城打工,打了半年工左右想学习一下真正的维修技术,也就是芯片级维修,毅然决然踏上了深圳的路。


在深圳有一家机构叫做迅维的机构,还算是在业内比较出名的这么一个机构,学习主板显卡的维修,学习电路知识,学习手机维修的技术。现在的我想想当时也不太明白我怎么敢自己一个人就往深圳冲,家里人怎么拦着我都没用,当时我就好像着了魔一样必须要去...


不过在深圳的生活真的很不错,那一年的时光仍旧是我现在非常怀念的,早晨有便宜好吃的肠粉、米粉、甜包,中午有猪脚饭、汤饭、叉烧饭,晚上偶尔还会吃一顿火锅,来自五湖四海的朋友也是非常的友好,教会了我很多东西,生活非常的不错。


3.jpg


3.回家开店


为什么说我工作了10年左右呢,因为我清楚记得我18岁那年在本地开了一个小店,一个电脑手机维修的小店。现在想想我当时也是非常的二笔,以下列举几个事件:



  1. 修了一个显示器因为没接地线烧了,还跟人家顾客吵了一架。

  2. 修苹果手机翘芯片主板线都翘出来了,赔了一块。

  3. 自己说过要给人家上门保修,也忘了,人家一打电话还怼了一顿。

  4. 因为打游戏不接活儿。


以上这几种情况比比皆是,哪怕我当时这么二笔也是赚了一些钱,还是可以维持的,唯一让我毅然决然转行的就是店被偷了,大概损失了顾客机器、我的机器、图纸、二手电脑等一系列的商品,共计7万元左右,至今仍没找回!


4.jpg


4.迷茫


接下来这三年就是迷茫的几年了,第一件事就是报成人大专,主要从事的行业就杂乱无章了,跟我爸跑过车,当过网吧网管,超市里的理货员,但是这些都不是很满意,也是从这时候开始接触了C和C++开始正式踏入自学编程的路,直到有一次在招聘信息里看到java,于是在b站开始自学java,当时学的时候jdk还是1.6,学习资料也比较古老,但是好歹是入了门了。


5.jpg


5.入职


在入门以后自我感觉非常良好,去应聘了一个外包公司,当时那个经理就问了我一句话,会SSM吗,我说会,于是我就这么入职了,现在想想还是非常幸运的。


当时的我连SSM都用不明白,就懂一些java基础,会一些线程知识,前端更是一窍不通,在外包公司这两年也是感谢前辈带我做一些项目,当时自己也是非常争气,不懂就学,回去百度、b站、csdn各种网站开始学习,前端学习了H5、JS、CSS还有一个经典前端框架,贤心的Layui。


干的这两年我除了学习态度非常认真,工作还是非常不在意,工作两年从来没有任何一个月满勤过,拖延症严重,出现问题从来就是逃避问题,职场的知识是一点也不懂,当时的领导也很包容我,老板都主持了我的婚礼哈哈哈。但是后来我也为我的嚣张买了单,怀着侥幸心理喝了酒开车,这一次事情真真正正的打醒了我,我以后不能这样了...


6.jpg


6.第二家公司


在第二家公司我的态度就变了很多很多 当时已经25岁了,开始真真正正是一个大人了,遵纪守法,为了父母和家人考虑,生活方面也慢慢的好了起来(在刚结婚两年和老婆经常吵架,从这时候开始到现在没有吵过任何架了就),生活非常和睦。工作方面也是从来不迟到早退,听领导的安排,认真工作,认真学习,认识了很多同行,也得到了一些人的认可,从那开始才开始学习springboot、mq、redis、ES一些中间件,学习了很多知识,线程知识、堆栈、微服务等一系列的知识,也算是能独当一面了。但好景不长,当时我的薪资已经到13K左右了,也是因为我们部门的薪资成本、服务器成本太大,入不敷出,公司决定代理大厂的产品而不是自研了,所以当时一个部门就这么毕业了...


7.png


7.现阶段公司


再一次找工作就希望去一些自研的大公司去做事情了,但是也是碍于学历,一直没有合适的,可以说是人厌狗嫌,好的公司看不上我,小公司我又不想去,直到在面试现在公司的时候聊得非常的好,也是给我个机会,说走个特批,让我降薪入职,大概每个月平均薪资10K左右(年终奖是大头),我也是本着这个公司非常的大也就来了,工作至今。


8.jpg


总结



  1. 任何时候想改变都不晚,改变不了别人改变自己。

  2. 面对问题绝对不能逃避,逃避没有任何用,只有面对才能更好的继续下去。

  3. 不要忘了自己为什么踏入这行,因为我想做游戏。

  4. 解决问题不要为了解决而解决,一定要从头学到尾,要不然以后出现并发问题无从下手。

  5. 任何事情都要合规合法。

  6. 工作了不要脱产做任何事情,我是因为家里非常支持,我妈至今都难以相信我能走到今天(我认为我大部分是运气好,加上赶上互联网浪潮的尾巴)。

  7. 最重要的,任何事情都没有家人重要,想回家就回家吧,挣钱多少放一边,IT行业找个副业还是非常简单的,多陪陪他们!


作者:妄也
来源:juejin.cn/post/7309645869644480522
收起阅读 »

Java开发者必备:Maven简介及使用方法详解!

今天我们来介绍一个在Java开发中非常重要的工具——Maven。如果你是一名Java开发者,那么你一定不会对Maven感到陌生。但是,对于一些新手来说,可能还不太了解Maven是什么,它有什么作用,以及如何使用它。接下来,就让我们一起来深入了解一下Maven吧...
继续阅读 »

今天我们来介绍一个在Java开发中非常重要的工具——Maven。如果你是一名Java开发者,那么你一定不会对Maven感到陌生。但是,对于一些新手来说,可能还不太了解Maven是什么,它有什么作用,以及如何使用它。接下来,就让我们一起来深入了解一下Maven吧!

一、maven简介

Maven是什么

Maven是一个项目管理工具,它包含了一个项目对象模型 (Project Object Model),一组标准集合,一个项目生命周期(Project Lifecycle),一个依赖管理系统(Dependency Management System),和用来运行定义在生命周期阶段(phase)中插件(plugin)目标(goal)的逻辑。maven是基于Ant 的构建工具,Ant 有的功能Maven 都有,额外添加了其他功能。

Maven提供了一套标准化的项目结构,所有IDE使用Maven构建的项目结构完全一样,所有IDE创建的Maven项目可以通用。

Maven是专门用于管理和构建Java项目的工具,它的主要功能有:

  • 提供了一套标准化的项目结构

  • 提供了一套标准化的构建流程(编译、测试、打包、发布 …)

  • 提供了一套依赖管理机制

Maven作用

  • 项目构建管理:maven提供一套对项目生命周期管理的标准,开发人员、和测试人员统一使用maven进行项目构建。项目生命周期管理:编译、测试、打包、部署、运行。

  • 管理依赖(jar包):maven能够帮我们统一管理项目开发中需要的jar包。

  • 管理插件:maven能够帮我们统一管理项目开发过程中需要的插件。

二、maven仓库

用过maven的同学,都知道maven可以通过pom.xml中的配置,就能够获取到想要的jar包,但是这些jar是在哪里呢?就是我们从哪里获取到的这些jar包?答案就是仓库。

仓库分为:本地仓库、第三方仓库(私服)和中央仓库。
Description

1、本地仓库

本地仓库:计算机中一个文件夹,自己定义是哪个文件夹。Maven会将工程中依赖的构件(Jar包)从远程下载到本机的该目录下进行管理。

maven默认的仓库是$user.home/.m2/repository目录。

本地仓库的位置可以在$MAVEN_HOME/conf/setting.xml文件中修改。

在文件中找到localRepository目录,修改对应内容即可
<localRepository>D:/maven/r2/myrepository</localRepository>

2、中央仓库

中央仓库:网上地址https://repo1.maven.org/maven2/

这个公共仓库是由Maven自己维护,里面有大量的常用类库,并包含了世界上大部分流行的开源项目构件。工程依赖的jar包如果本地仓库没有,默认从中央仓库下载。

由于maven的中央仓库在国外,所以下载速度比较慢,所以需要配置国内的镜像地址。

在配置文件中找到mirror标签,添加以下内容即可。

<mirror>
<id>alimaven</id>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/groups/public/</url>
<mirrorOf>central</mirrorOf>
</mirror>

3、第三方仓库(私服)

第三方仓库,又称为内部中心仓库,也称为私服。

私服:一般是由公司自己设立的,只为本公司内部共享使用。它既可以作为公司内部构件协作和存档,也可作为公用类库镜像缓存,减少在外部访问和下载的频率,公司单独开发的私有jar可放置到私服中。(使用私服为了减少对中央仓库的访问)

注意:连接私服,需要单独配置。如果没有配置私服,默认不使用

三、Maven的坐标

什么是坐标?
Maven中的坐标是资源的唯一标识,使用坐标来定义项目或引入项目中需要的依赖。
Description

Maven坐标的主要组成:

  • groupId:定义当前Maven项目隶属组织名称(通常是域名反写,例如:com.baidu)

  • artifactId:定义当前Maven项目名称(通常是模块名称,例如 order-service、goods-service)

  • version:定义当前项目版本号

你还在苦恼找不到真正免费的编程学习平台吗?可以试试云端源想!课程视频、在线书籍、在线编程、实验场景模拟、一对一咨询……你想要的全部学习资源这里都有,重点是统统免费!点这里即可查看

四、Maven的三套生命周期

什么是生命周期

在Maven出现之前,项目构建的生命周期就已经存在,软件开发人员每天都在对项目进行清理,编译,测试及部署。虽然大家都在不停地做构建工作,但公司和公司间,项目和项目间,往往使用不同的方式做类似的工作。
Description
Maven的生命周期就是为了对所有的构建过程进行抽象和统一。Maven从大量项目和构建工具中学习和反思,然后总结了一套高度完美的,易扩展的生命周期。

这个生命周期包含了项目的清理,初始化,编译,测试,打包,集成测试,验证,部署和站点生成等几乎所有构建步骤。

Maven的生命周期是抽象的,这意味着生命周期本身不做任何实际工作,在Maven的设计中,实际任务(如源代码编译)都交由插件来完成。

Maven的三套生命周期

Maven拥有三套相互独立的生命周期,分别是clean,default和site。

Description

clean生命周期

clean生命周期的目的是清理项目,它包含三个阶段:

  • pre-clean 执行一些清理前需要完成的工作

  • clean 清理上一次构建生成的文件

  • post-clean 执行一些清理后需要完成的工作

default生命周期

default生命周期定义了真正构建项目需要执行的所有步骤,它是所有生命周期中最核心的部分。其中的重要阶段如下:

  • compile :编译项目的源码,一般来说编译的是src/main/java目录下的java文件至项目输出的主classpath目录中

  • test :使用单元测试框架运行测试,测试代码不会被打包或部署

  • package :接收编译好的代码,打包成可以发布的格式,如jar和war

  • install:将包安装到本地仓库,供其他maven项目使用

  • deploy :将最终的包复制到远程仓库,供其他开发人员或maven项目使用

site生命周期

site生命周期的目的是建立和发布项目站点,maven能够基于pom文件所包含的项目信息,自动生成一个友好站点,方便团队交流和发布项目信息。该生命周期中最重要的阶段如下:

  • site :生成项目站点文档

  • Maven生命周期相关命令

  • mvn clean:调用clean生命周期的clean阶段,清理上一次构建项目生成的文件

  • mvn compile :编译src/main/java中的java代码

  • mvn test :编译并运行了test中内容

  • mvn package:将项目打包成可发布的文件,如jar或者war包;

  • mvn install :发布项目到本地仓库

Maven生命周期相关插件

Maven的核心包只有几兆大小,核心包中仅仅定义了抽象的生命周期。生命周期的各个阶段都是由插件完成的,它会在需要的时候下载并使用插件,例如我们在执行mvn compile命令时实际是在调用Maven的compile插件来编译。

我们使用IDEA创建maven项目后,就不需要再手动输入maven的命令来构建maven的生命周期了。IDEA给每个maven构建项目生命周期各个阶段都提供了图形化界面的操作方式。

具体操作如下:

  • 打开Maven视图:依次打开Tool Windows–>Maven Projects

  • 执行命令:双击Lifecycle下的相关命令图标即可执行对应的命令(或者点击运行按钮)

Description

五、maven的版本规范

maven使用如下几个要素来唯一定位某一个jar:

  • Group ID:公司名。公司域名倒着写

  • Artifact ID:项目名

  • Version:版本

发布的项目有一个固定的版本标识来指向该项目的某一个特定的版本。maven在版本管理时候可以使用几个特殊的字符串SNAPSHOT,LATEST ,RELEASE。比如"1.0-SNAPSHOT"。

各个部分的含义和处理逻辑如下说明:

  • SNAPSHOT 正在开发中的项目可以用一个特殊的标识,这种标识给版本加上一个"SNAPSHOT"的标记。

  • LATEST 指某个特定构件的最新发布,这个发布可能是一个发布版,也可能是一个snapshot版,具体看哪个时间最后。

  • RELEASE 指最后一个发布版。

六、maven项目之间的关系

依赖关系

  • 标签把另一个项目的jar引入到当过前项目

  • 自动下载另一个项目所依赖的其他项目

Description

继承关系

  • 父项目是pom类型,子项目jar 或war,如果子项目还是其他项目的父项目,子项目也是pom 类型。

  • 有继承关系后,子项目中出现标签

  • 如果子项目和和与父项目相同,在子项目中可以不配置和父项目pom.xml 中是看不到有哪些子项目,在逻辑上具有父子项目关系。

父项目
<groupId>cn.zanezz.cn</groupId>
<artifactId>demoparent</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
子项目
<parent>
<artifactId>demoparent</artifactId>
<groupId>cn.zanezz.cn</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>war</packaging>
<artifactId>child2</artifactId>

聚合关系

  • 前提是继承关系,父项目会把子项目包含到父项目中。

  • 子项目的类型必须是Maven Module 而不是maven project

  • 新建聚合项目的子项目时,点击父项目右键新建Maven Module

子项目中的pom.xml
<parent>
<artifactId>demoparent</artifactId>
<groupId>cn.zanezz.cn</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
父项目中的pom.xml
<groupId>cn.zanezz.cn</groupId>
<artifactId>demoparent</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>child1</module>
<module>child2</module>
</modules>

聚合项目和继承项目区别

  • 在语意上聚合项目父项目和子项目关系性较强;

  • 在语意上单纯继承项目父项目和子项目关系性较弱。

Maven是一个非常强大的工具,它可以帮助我们更好地管理和构建Java项目。如果你是Java开发者,那么你一定不能错过这个工具。希望这篇文章能帮助你更好地理解和使用Maven,祝你在Java开发的道路上越走越远!

收起阅读 »

JS: function前面加!,引发思考🤔

web
简介 我们基本都知道,函数的声明方式有这两种 function msg(){alert('msg');}//声明式定义函数 var msg = function(){alert('msg');}//函数赋值表达式定义函数 但其实还有第三种声明方式,Func...
继续阅读 »

简介


我们基本都知道,函数的声明方式有这两种


function msg(){alert('msg');}//声明式定义函数

var msg = function(){alert('msg');}//函数赋值表达式定义函数

但其实还有第三种声明方式,Function构造函数


var msg = new function(msg) {
alert('msg')
}

等同于


function msg(msg) {
alert('msg')
}

函数的调用方式通常是方法名()

但是,如果我们尝试为一个“定义函数”末尾加上(),解析器是无法理解的。


function msg(){
alert('message');
}();//解析器是无法理解的

定义函数的调用方式应该是 print(); 那为什么将函数体部分用()包裹起来就可以了呢?

原来,使用括号包裹定义函数体,解析器将会以函数表达式的方式去调用定义函数。 也就是说,任何能将函数变成一个函数表达式的作法,都可以使解析器正确的调用定义函数。而 ! 就是其中一个,而 + - || ~ 都有这样的功能。


但是请注意如果用括号包裹函数体,然后立即执行。这种方式只适用一次性调用该函数,涉及到了一个作用域问题,当你想复用该函数的时候,会如下问题:


image.png

可如果你想复用该函数的话,就可按先声明函数,然后再调用函数,在同一个父级作用域下,可以复用该函数,如下:


var msg = function(msg) {}
msg();

关于这个问题,后面会进一步分析


function前面加 ! ?


自执行匿名函数:


在很多js代码中我们常常会看见这样一种写法:


(function( window, undefined ) {
// code
})(window);

这种写法我们称之为自执行匿名函数。正如它的名字一样,它是自己执行自己的,前一个括号是一个匿名函数,后一个括号代表立即执行


前面也提到 + - || ~这些运算符也同样有这样的功能


(function () { /* code */ } ()); 
!function () { /* code */ } ();
~function () { /* code */ } ();
-function () { /* code */ } ();
+function () { /* code */ } ();

image.png

① ( ) 没什么实际意义,不操作返回值


② ! 对返回值的真假取反


③ 对返回值进行按位取反(所有正整数的按位取反是其本身+1的负数,所有负整数的按位取反是其本身+1的绝对值,零的按位取反是 -1。其中,按位取反也会对返回值进行强制转换,将字符串5转化为数字5,然后再按位取反。
false被转化为0,true会被转化为1。
其他非数字或不能转化为数字类型的返回值,统一当做0处理)


④ ~
+、- 是对返回值进行数学运算 ( 可见返回值不是数字类型的时候 +、- 会将返回值进行强制转换,字符串强制转换后为NaN)


先从IIFE开始介绍 (注:这个例子是参考网上


IIFE(Imdiately Invoked Function Expression 立即执行的函数表达式)


function(){
alert('IIFE');
}

把这个代码放在console中执行会报错


image.png


因为这是一个匿名函数,要想让它正常运行就必须给个函数名,然后通过函数名调用。

其实在匿名函数前面加上这些符号后,就把一个函数声明语句变成了一个函数表达式,是表达式就会在script标签中自动执行


所以现在很多对代码压缩和编译后,导出的js文件通常如下:


(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i=""+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do 

运算符


也许这里有人会疑惑,运算符为何能将声明式函数,转译成函数表达式,这里就涉及到了一个概念解析器


程序在运行之前需要经过编译或解释的过程,把源程序翻译成为字节码,但是在翻译之前,需要把字符串形式的程序源码解析为语法树或者抽象语法树等数据结构,这就需要用到解析器


那么什么是解析器?


所谓解析器(Parser),一般是指把某种格式的文本(字符串)转换成某种数据结构的过程。最常见的解析器(Parser),是把程序文本转换成编译器内部的一种叫做抽象语法树(AST)的数据结构,此时也叫做语法分析器(Parser)。也有一些简单的解析器(Parser),用于处理CSV、JSON,XML之类的格式


JS解析器在执行第一步预解析的时候,会从代码的开始搜索直到结尾,只去查找var、function和参数等内容。一般把第一步称之为“JavaScript的预解析”。而且,当找到这些内容时,所有的变量,在正式运行代码之前,都提前赋了一个值:未定义;所有的函数,在正式运行代码之前,都是整个函数块。让解析器识别到是一个表达式,那就得加上特殊符号来让其解析器识别出来,比如刚才提到的特殊运算符。


解析过程大致如下:


1、“找一些东西”: var、 function、 参数;(也被称之为预解析)


备注:如果遇到重名分为以下两种情况:遇到变量和函数重名了,只留下函数;遇到函数重名了,根据代码的上下文顺序,留下最后一个。


2、逐行解读代码。


备注:表达式可以修改预解析的值 (可以自行查阅文档,这就是后面说到的内容)


函数声明与函数定义


函数声明
一般相对规范的声明形式为:fucntion msg(void) 注意是有分号


function msg() 

函数定义 function msg()注意没有分号


{
alert('IIFE');
}

函数调用


这样是一个函数调用


msg();

函数声明加一个()就可以调用函数了


function msg(){
alert('IIFE');
}()

就这样

但是我们按上面在console中执行发现出错了


image.png


因为这样的代码混淆了函数声明和函数调用,以这种方式声明的函数 `msg`,就应该以 `msg()` 的方式调用。

若改成(function msg())()就是这样的一个结构体: (函数体)(IIFE),能被Javascript的解析器识别并正常执行


从Js解析器的预解析过程了解到:


解析器都能识别一种模式:使用括号封装函数。对于解析器来说,这几乎总是一个积极的信号,即函数需要立即执行。如果解析器看到一个左括号,紧接着是一个函数声明,它将立即解析这个函数。可以通过显式地声明立即执行的函数来帮助解析器加快解析速度


那么也就是说,括号的作用,就是将一个函数声明,让解析器识别为一个表达式,最后由程序执行这个函数


总结


任何消除函数声明和函数表达式间歧义的方法,都可以被Javascript解析器正确识别


赋值,逻辑,甚至是逗号,各种操作符,只要是解析器支持且用来识别的特殊符号都可以用作消除歧义的方式方法,而!function()(function()), 都是其中转换成表达式的一种方式。


测试


至于优先使用哪一个,推荐(), 而其他运算符,相对于多了一步执行步骤,比如+(表达式),那就是,立即执行+运算符运算,
大致测了一下:


image.png


结论


从测试结果的截图中我们能大致的看到,(IIFE)方式,比运算符快的是一个级别(进一位数的速度),如果说立即执行()的时间复杂度是O(n),那么运算符就是O(10n),当然这也只是粗略的测试,而且在现有的浏览器解析速度,时间基数小到可以忽略不计,所以看个人需求,写法就是萝卜白菜,大家各有所好,看个人


作者:糖墨夕
来源:juejin.cn/post/7203734711780081722
收起阅读 »

重新认识下网页水印

web
使用背景图图片 单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。 如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现: <style>...
继续阅读 »

使用背景图图片


单独使用 css 实现,使用 backgroundImage,backgroundRepeat 将背景图片平铺到需要加水印的容器中即可。
如果希望实现旋转效果,可以借助伪元素,将背景样式放到伪元素中,旋转伪元素实现:


<style>
.watermark {
position: relative;
overflow: hidden;
background-color: transparent;
}
.watermark::before {
content: '';
position: absolute;
width: 160%;
height: 160%;
top: -20%;
left: -20%;
z-index: -1;
background-image: url('./watermark.png');
background-position: 0 0;
background-origin: content-box;
background-attachment: scroll;
transform: rotate(-20deg);
background-size: auto;
background-repeat: round;
opacity: 0.3;
pointer-events: none;
}
</style>

动态生成div


根据水印容器的大小动态生成div,div内可以任意设置文本样式和图片,借助userSelect禁止用户选中文本水印;


const addDivWaterMark = (el, text) => {
const { clientWidth, clientHeight } = el;
const waterWrapper = document.createElement('div');
waterWrapper.className = "waterWrapper";
const column = Math.ceil(clientWidth / 100);
const rows = Math.ceil(clientHeight / 100);
// 根据容器宽高动态生成div
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div');
wrap.className = "water";
wrap.innerHTML = `<div class="water-item">${text}</div>`
waterWrapper.appendChild(wrap)
}
el.append(waterWrapper)
}

Canvas写入图片做背景水印


将图片写入Canvas然后将Canvas作为背景图


  const img = new Image();
const { ctx, canvas } = createWaterMark(config);
img.onload = function () {
ctx.globalAlpha = 0.2;
ctx.rotate(Math.PI / 180 * 20);
ctx.drawImage(img, 0, 16, 180, 100);
canvasRef.value.style.backgroundImage = `url(${canvas.toDataURL()})`
};
img.src = ImageBg;

Canvas写入文字做背景水印


将文字写入Canvas然后将Canvas作为背景图


 const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fillStyle;
ctx.globalAlpha = opacity;
ctx.font = font
ctx.rotate(Math.PI / 180 * rotate);
ctx.fillText(text, 0, 50);
return canvas

Svg做水印


通过svg样式来控制水印样式,再将svg转换成base64的背景图


  const svgStr =
`<svg xmlns="http://www.w3.org/2000/svg" width="180px" height="100px">
<text x="0px" y="30px" dy="16px"
text-anchor="start"
stroke="#000"
stroke-opacity="0.1"
fill="none"
transform="rotate(-20)"
font-weight="100"
font-size="16"> 前端小书童</text>
</svg>`
;
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;

shadowDom水印


使用customElements自定义个一个标签(可以使用其他任意标签,不过注意shadow DOM会使起同级的元素不显示。)
可以像shadow DOM写入style样式和水印节点(可以使用背景或者div形式)
shadow DOM内部实现的样式隔离不用担心写入的style影响页面其他元素样式,这个特性在微前端的实现中也被广泛使用。


 class ShadowMark extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const wrapContainer = document.createElement('div')
const style = document.createElement('style');
style.textContent = `
.wrapContainer {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
}
.watermark-item {
display: flex;
font-size: 16px;
opacity: .3;
transform: rotate(-20deg);
user-select: none;
white-space: nowrap;
justify-content: center;
align-items: center;
}`
;
const waterHeight = 100
const waterWidth = 100
const { clientWidth, clientHeight } = document.querySelector('.shadow-watermark')
const column = Math.ceil(clientWidth / waterWidth)
const rows = Math.ceil(clientHeight / waterHeight)
wrapContainer.setAttribute('class', "wrapContainer")
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div')
wrap.setAttribute('class', 'watermark-item')
wrap.style.width = waterWidth + 'px'
wrap.style.height = waterHeight + 'px'
wrap.textContent = "前端小书童"
wrapContainer.appendChild(wrap)
}
shadowRoot.appendChild(style);
shadowRoot.appendChild(wrapContainer)
}
}
customElements.define('shadow-mark', ShadowMark);

盲水印


canvas画布(canvas.getContext('2d'))调用 getImageData 得到一个 ArrayBuffer,用于记录画布每个像素的 rgba 值


r: Red取值范围0255
g: Green取值范围0
255
b:Blue取值范围0255
a:Alpha 透明度取值范围0
1,0代表全透明
可以理解为每个像素都是通过红、绿、蓝三个颜色金额透明度来合成颜色


方案一:低透明度方案的暗水印


当把水印内容的透明度 opacity 设置很低时,视觉上基本无法看到水印内容,但是通过修改画布的 rgba 值,可以使水印内容显示出来。
选择固定的一个色值例如R,判断画布R值的奇偶,将其重置为0或者255,低透明的内容就便可以显示出来了。


const decode = (canvas, colorKey, flag, otherColorValue) => {
const ctx = canvas.getContext('2d');
const originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
let data = originalData.data;
for (let i = 0; i < data.length; i++) {
//筛选每个像素点的R值
if (i % 4 == colorKey) {
if (data[i] % 2 == 0) {
//如果色值为偶数
data[i] = flag ? 255 : 0;
} else {
//如果色值为奇数
data[i] = flag ? 0 : 255;
}
} else if (i % 4 == 3) {
//透明度不作处理
continue;
} else {
// 关闭其他色值
if (otherColorValue !== undefined) {
data[i] = otherColorValue
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案二:将水印内容以像素偏差记录到画布中


用画布和水印后的画布绘制的像素进行ArrayBuffer对比,在存在水印像素的位置(水印画布透明度不为0)修改图片画布的奇偶,这样通过上面指定色值和奇偶去解码时,修改的文本像素就会被显示出来;


const encode = (ctx, textData, color, originalData) => {
for (let i = 0; i < originalData.data.length; i++) {
// 只处理目标色值
if (i % 4 == color) {
// 水印画布透明度为0
if (textData[i + offset] === 0 && (originalData.data[i] % 2 === 1)) {
// 放置越界
if (originalData.data[i] === 255) {
originalData.data[i]--;
} else {
originalData.data[i]++;
}
// 水印画布透明度不为0
} else if (textData[i + offset] !== 0 && (originalData.data[i] % 2 === 0)) {
originalData.data[i]++;
}
}
}
ctx.putImageData(originalData, 0, 0);
}

方案三:数字加密


在图像信号的频域(变换域)中隐藏信息要比在空间域(上面得到的像素颜色的ArrayBuffer)中隐藏信息具有更好的防攻击性。
这部分暗水印的实现,可以直接使用阿里云提供给的api,不过需要图片资源藏到的阿里云的OSS下;


MutationObserver


可以发现上面水印基本都是通过增加节点或者背景图的形式来实现,那用户其实可以通过屏蔽样式或者删除Dom来消除水印,那么我们可以借用MutationObserver来监听下水印dom的变化,来阻止用户以这种形式来消除水印;



代码



以上代码见:github.com/wenjuGao/wa…


线上效果:watermark-demo.vercel.app/



参考:



http://www.cnblogs.com/88223100/p/…


blog.csdn.net/bluebluesky…


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


作者:前端小书童
来源:juejin.cn/post/7208465670991872061
收起阅读 »

人走茶凉?勾心斗角?职场无友谊?

你和同事之间存在竞争关系 要不要把工作关系维护成伙伴关系 明枪暗箭防不胜防 背后捅刀子往往最不设防 大家是否在职场上交友是有也遇到过以上困扰呢? 不要在职场上交“朋友”,而是要寻找“盟友”。 这两者的区别在于应对策略: 我们会愿意为“朋友”牺牲自己的利益,像是...
继续阅读 »

你和同事之间存在竞争关系


要不要把工作关系维护成伙伴关系


明枪暗箭防不胜防


背后捅刀子往往最不设防


大家是否在职场上交友是有也遇到过以上困扰呢?


不要在职场上交“朋友”,而是要寻找“盟友”。


这两者的区别在于应对策略:


我们会愿意为“朋友”牺牲自己的利益,像是一张年卡。


而结交“盟友”就是为了一起争取更多利益,《孔乙己》说得好:“这次是现钱,酒要好。”


所以,在职场上的“受欢迎”和社交场、朋友圈上的“受欢迎”之间有着本质的区别:


你和你的同事未必真心喜欢彼此,但在日常相处当中能够客气、友善地交往。


大家需要寻找盟友时会第一个想到你,在争斗冲突时会尽量绕开你,这就是一种非常理想的“受欢迎”状态。 不要在职场上寻求友谊和爱,这件事是不对的。


在这里给大家列出一个在职场上受欢迎的清单。


1.实力在及格线以上


这是一切的前提。职场新人要“先活下来,再做兄弟”,稳住了工作能力这个基本面,才有资格和同事谈交情。


实力不够的人会拖累整个团队、增加所有人的工作量,大家恨都来不及,绝对不会和他称兄道弟。


实力强可以表现为实力本身,在初级职位上,也可以表现为潜力。


极少数特别强大的人可能从一开始就能很好地完成工作,但是大部分人在新加入一个团队时都需要经过一段时间的磨合,在这个过程中有欠缺和不足都是正常的,你所表现出来的敬业精神、学习能力和进步的速度才是大家对你进行评价的关键。


刚入职的新人,对于要做的事情完全没有概念,但是为人极勤奋又上进,给他布置的任务会完成得特别扎实,每一天都在飞快地进步。这样的人在职场上永远都能收获一大把来自他人的橄榄枝。


2.比较高的自尊水平


高自尊的人对自己评价高,要求也高,又能够带着欣赏的眼光去看周围的人,他们不光是很好的父母、伴侣和朋友,同时也是职场上最好的结盟对象。


高自尊的人往往拥有很多优秀的品质,同时他们也能够理解“大局”,和他们合作不用在鸡毛蒜皮的细节上纠缠推诿,可以把精力全部用来开疆拓土,极大地降低团队的内耗。


如果你是一个高自尊的人,在日常生活中表现出了自律和很好的品行,就会收获高自尊同类的赞赏。有些低自尊的人可能会认为你的言行是在“装X”,别犹豫,把他们从你的结交名单当中划掉,高自尊会帮你筛掉一批最糟糕的潜在合作者。


如果你是一个部门的领导者,记得要维护高自尊的下属,他们都是潜在的优秀带队者,给他们一个位子就可以坐上来自己动,给他们一点精神鼓励和支持,他们就会变得无所不能。


即使高自尊的手下可能某些地方让你感到嫉妒或者冒犯(这是常见的,嫉妒是每个人都一定会有的情感),也绝对不要默许或者纵容低自尊的妄人跑去伤害他们,否则会伤了大家的心,事业就难以成功了。


“朕可以敲打丞相,但你算什么东西”就是对这种低自尊妄人最好的态度。


3.嘴严,可靠


在任何一个群体当中,多嘴多舌的人都不会受到尊重,而在职场上,嘴不严尤其危险。


如果你是一个爱说是非的人,围绕在你周围的只会是一帮同样没正事、低级趣味的家伙。你会被打上“不可靠”的标记,愿意和你交流的人越来越少,大家等着看你什么时候因为多嘴闯祸,而强者根本不会和你为伍。


有些同学曾经给我留言说,自己很内向,不知道如何跟同事拉近关系。内向的人最适合强调自己的“嘴严”和“可靠”,在职场上,这两项品质远比“能说会道”更让人喜欢。


4.随和,有分寸


体面的人不传闲话,也不会轻易对旁人发表议论。


“思想可以特立独行,生活方式最好随大流”,这是对自己的要求,而他人的生活方式是不是合理,不是我们能评价的。


哪怕是最亲近的人,都未必能知晓对方的全部经历和心里藏着的每一件小事。在职场上大家保持着客气有礼的距离,就更不可能了解每个人做事的出发点和逻辑,“看不懂”是正常的,但是完全没有必要“看不惯”。如果还要大发议论,把自己的“看不惯”到处传播,你的伙伴就只会越来越少。


有人说在北上广深这样的大城市,人和人之间距离遥远,缺人情味,太冷漠。


这不是冷漠,而是对“和自己不一样”的宽容,这份宽容就是我们在向文明社会靠拢的标志。


5.懂得如何打扮


还记得斯大林的故事吗?在他离开校园之后,从头到脚都经过精心设计,不是为了精神好看,而是要让自己看起来就像一位投身革命事业的进步青年。


有句老话叫做“先敬罗衣后敬人”,本意是讽刺那些根据衣饰打扮来评价一个人的现象。我们自己在做判断的时候要尽量避免受到这类偏见的影响,但是对他人可能存在的偏见一定要心中有数。人是视觉动物,穿着打扮是“人设(人物设定)”的一部分,在我们开口说话之前,衣饰鞋袜就已经传达了无数信息。


想要成为职场当中受欢迎的人,穿着打扮的风格就要和公司的调性保持一致,最安全的做法是向你的同事靠拢。


在一个风格统一的群体当中,“与众不同”这件事自带攻击性。如果在事业单位之类的上年纪同事比较多的地方上班,马卡龙色的衣服和颜色夸张的口红,最好等到下班时间再上身。


这不是压抑天性,而是自我保护和职业精神。


6.和优秀的人站在一起


在职场上,优秀的人品质都是相似的:勤奋,自律,不断精进。如果发现了这样的同事,就要尽量和他们保持良好关系。


但是,单纯的日常沟通并不足以让你们成为盟友,正式结盟往往是通过利益交换和分享:当你遇到棘手的工作任务,就可以主动邀请对方共同跟进,同时将一部分利益让出去。愉快的合作是关系飞跃的最好契机。


优秀的人能认可的,通常也都是自己的同类。如果你能获得他们的称许和背书,在同事当中的地位自然会有所提升。


7.知道如何求助


前两天有一位关系户同学留言说,自己即将去实习,因为家人的关系可以得到一些行业资深专家的指点,问自己应该如何表现,是不是不懂就要问,像“好奇宝宝”一样,对方就会觉得自己好学上进。


我告诉她说,不要上去就问,有任何疑惑都先用搜索引擎找一下答案,如果找不出来,再带着你搜到的细节去询问那些资深前辈。


互联网时代有个很大的变化,就是人们获取信息的成本大大降低。善用搜索引擎寻找答案,就能更快、更精准、更全面地找到自己想要的东西,这种方式比跑到对方工位边用嘴问效率高得多。


凡事都问,只会让人觉得你的文字阅读能力有限,同时既不把自己的时间当回事,也不尊重别人的时间。尤其对方还是行业中的专家,他们的时间一定比实习生的宝贵多了。如果网上找不到答案,再带着细节去仔细咨询,这样的请教才是高效的,才能证明你是一个“好学上进”的人。


职场不是校园,不会再有一群老师专门负责手把手地教你,不轻易占用其他同事的时间会让你成为一个自立、有分寸、受尊重的人。毕业之后,你取得进步的速度、最终的上升空间,都和使用搜索引擎寻找答案的能力呈正相关。


8.技巧地送出小恩小惠


小恩小惠带两个“小”字,并不意味着这是一种微末小技。事实上,即使是最普通的零食,只要讲究得法,都可以送到人心里。


你的同事当中有没有因为宗教信仰而忌口的情况?


甲和乙爱吃辣,丙和丁爱吃甜,是不是两种口味都来上一点?


要留心同事的自我暴露,最好是用一个小本本记下来,关键时刻可能派上大用场。大家都是成年人,不会像孩子一样轻易被小恩小惠打动,打动我们的往往是“你把我放在心上”的温暖。


9.良好的情绪管理能力


很多时候这是个隐藏特征,但是自带“一票否决”属性:平时表现得沉着稳重,周围同事们不会有特别明显的感觉,然而歇斯底里和失控只要有一次,之前苦心经营的人设就会全面崩塌。情绪不稳定的人一般没人敢惹,但是也没人会在意了:你会被视为一个“病人”,很难再有大的发展。


已经发泄出去的情绪不能收回来,这个时候不要反复陷入纠结和悔恨,待在情绪里不出来,钱花出去了就不要去想,不要去比价。


如果情绪失控了,应该立刻做到的是原谅自己,然后考虑如何不再有下一次失控。要知道大多数人一辈子都至少会换三四次工作,了不起是换个地方,重新再来。


有的人特别幸运,天生长得好看,容易被人喜欢。


如果不是让人眼前一亮的高颜值人士,就不要太心急了。


成为一个自律、行为可以预期的人,也能慢慢地被别人喜欢。


人生很长,被人喜欢这件事,我们不用赶时间。


作者:程序员小高
来源:juejin.cn/post/7255589558996992059
收起阅读 »

你的代码着色好看吗?来这里看看吧!

web
如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。 那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。 你只需要使用一个叫做 highlight.js 的第三方...
继续阅读 »

如果你想在网页上展示一些代码,你可能会遇到一个问题:代码看起来很单调,没有任何颜色或格式,这样的代码不仅不美观,也不利于阅读和理解。


那么,有没有什么办法可以让代码变得更漂亮呢?答案是有的,而且很简单。


你只需要使用一个叫做 highlight.js 的第三方库,就可以轻松实现代码着色的效果。



highlight.js 是一个非常强大和流行的库,它可以自动识别和着色超过 190 种编程语言。


它支持多种主题和样式,让你可以根据自己的喜好选择合适的配色方案。


在本文中,子辰将向你介绍如何使用 highlight.js 来为你的代码着色,以及它的基本原理和优势。


让我们开始吧!


如何使用 highlight.js


使用 highlight.js 的方法有两种:一种是通过 npm 下载并安装到你的项目中,另一种是通过 CDN 引入到你的网页中。


这里我们以 CDN 的方式为例,如果你想使用 npm 的方式,可以参考官方文档。


首先,我们需要在网页中引入 highlight.js 的 JS 文件和 CSS 文件。


JS 文件是核心文件,负责识别和着色代码,CSS 文件是样式文件,负责定义代码的颜色和格式。



我们可以从 CDN 中选择一个合适的 JS 文件和 CSS 文件。


highlight.js 提供了多个 CDN 服务商,你可以根据自己的需求选择一个,这里我们以 jsDelivr 为例。


JS 文件的链接如下:


<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>

CSS 文件的链接则需要根据你想要的主题来选择。


highlight.js 提供了很多主题,你可以在官网上预览每个主题的效果,并找到对应的 CSS 文件名,这里我们以 github-dark 为例。


CSS 文件的链接如下:


<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css">

将上面两个链接分别放到网页的 head 标签中,就完成了引入 highlight.js 的步骤。


接下来,我们需要在网页中写一些代码,并用 pre 标签和 code 标签包裹起来。


pre 标签用于保留代码的格式,code 标签用于标识代码内容。例如:


<pre>
<code id="code-area">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
height: 100vh;
width: 100vw;
}
</code>
</pre>

注意,我们给 code 标签添加了一个 id 属性,方便后面通过 JS 获取它。


最后,我们需要在网页中添加一些 JS 代码,来调用 highlight.js 的方法,实现代码着色的功能。


highlight.js 提供了两个主要的方法:highlightElement 和 highlight。


这两个方法都可以实现代码着色的效果,但是适用于不同的场景。


highlightElement


highlightElement 方法适用于当你的代码是直接写在网页中的情况。


这个方法接受一个元素作为参数,并将该元素内部的文本内容进行着色处理。例如:


// 获取 code 元素
const codeEle = document.getElementById("code-area");
// 调用 highlightElement 方法,传入 code 元素
hljs.highlightElement(codeEle);

如果一切顺利,你应该能看到类似下图的效果:



代码已经被着色了,并且你可以看到代码被替换成了一个个标签,标签被加上了样式。


在最后的原理里我们在详细的说一下。


highlight


highlight 方法适用于当你的代码是通过 Ajax 请求获取到的纯文本数据的情况。


这个方法接受一个字符串作为参数,并返回一个对象,包含着色后的文本内容和代码的语言。例如:


<script>
const codeEle = document.getElementById('code-area')
// 比如说现在 code 就是 Ajax 返回的数据,lang 就是代码语言,content 就是代码内容
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
// 我们接下来可以使用 hljs.highlight,将代码内容与代码语言传入进去
const result = hljs.highlight(code.content, {
language: code.lang
})
// 它会返回一个结果,我们打印到控制台看看
console.log('result >>> ', result)
</script>


我们可以看到,打印出来的是一个对象,code 是它原始的代码,language 是它的语言,而 value 就是它着色后的代码。


那么现在要做的就是将 value 添加到 code 元素里边去。


<script>
const code = {
lang: 'css',
content: `
* {
margin: 0;
padding: 0;
}`

}
const result = hljs.highlight(code.content, {
language: code.lang
})
const codeEle = document.getElementById('code-area')
codeEle.innerHTML = result.value
</script>


我们可以看到,代码确实被着色了,但是和之前的有所差别,我们看一下是什么原因。



打开控制后我们发现,用这种方式 code 元素就没有办法被自动加上类样式了,所以说我们就需要手动给 code 加上类样式才可以。


// 通过 className 为 code 手动添加类样式,并添加类的语言
codeEle.className = `hljs language-${code.lang}`

highlight.js 的语言支持


无论使用哪种方法,都需要注意指定代码所属的语言。


如果不指定语言,highlight.js 会尝试自动识别语言,并可能出现错误或不准确的结。


指定语言可以通过两种方式:



  • 在 code 标签中添加 class 属性,并设置为 language-xxx 的形式,其中 xxx 是语言名称。

  • 在调用 highlightElement 或 highlight 方法时,在第二个参数中传入一个对象,并设置 language 属性为语言名称。



上图是 highlight.js 支持的语言,可以看到有很多种,需要用其他语言的时候,language 设置成指定的语言名称就可以了。


原理


它的原理你可能已经猜到了,在 highlightElement 里我们简单说了一下,现在再看下图:



之所以可以实现着色,其实就是查找和替换的过程,将原来的纯文本替换为元素标签包裹文本,元素是可以加上样式的,而样式就是我们引入的 css 文件。


这就是它的基本原理了。


总结


其实有时候我们去设置 Mackdown 的自定义样式呢,在代码区域设置的时候也是这样设置的,当然类样式的名字呢,基本上都是标准的格式。


好了,这个库分享介绍给你了,库的原理也为你做了简单的科普,希望对你有所帮助。


如果你有什么问题或建议,请在评论区留言,如果你觉得这篇文章有用,请点赞收藏或分享给你的朋友!


作者:子辰Web草庐
来源:juejin.cn/post/7245584147456507965
收起阅读 »

😲什么!!一个开关要这么花里胡哨??

web
前言 前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚...
继续阅读 »

前言


前几天我的朋友突然找上我,说他公司产品要他做个很花哨的开关特效,我一想一个开关而已,还能花哨到哪去,无非就是加点动画特效吗,随后我承认我低估了这个产品的脑洞,需求是要一个粉粉嫩嫩的爱心开关,关的时候背景色是白的,打开后要粉色,而且爱心开关按钮是从左心房滚动到右心房(这是我朋友对产品心里话:************ 😄)随后我也是去翻了一下收藏集,找了一个效果给了他,让他自己再根据公司需求进行改动


结构


这里我们利用label标签对开关按钮及爱心的点击触发效果,内部使用一个复选框跟一个svg图标来进行布局


<label class="box">
<!-- 复选框,有选中状态 -->
<input type="checkbox">

<!-- 心形图标 -->
<svg viewBox="0 0 33 23" fill="pink">
<path
d="M23.5,0.5 C28.4705627,0.5 32.5,4.52943725 32.5,9.5 C32.5,16.9484448 21.46672,22.5 16.5,22.5 C11.53328,22.5 0.5,16.9484448 0.5,9.5 C0.5,4.52952206 4.52943725,0.5 9.5,0.5 C12.3277083,0.5 14.8508336,1.80407476 16.5007741,3.84362242 C18.1491664,1.80407476 20.6722917,0.5 23.5,0.5 Z">

</path>
</svg>
</label>


svg图标大家可以复制这个或者自己去网上找一个图标也可以,不过如果是网上找的则需要自己去重新计算开关打开和关闭的动画位置


样式


结构有了开始写样式,让开关好看点



  • 初始化


        * {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 解决手机浏览器点击有选框的问题 */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}


  • 大盒子居中,盒子样式及移入鼠标样式,svg样式调整


        body {
/* 常规居中显示,简单背景色 */
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
min-height: 100vh;
background-color: #f6f6ff;
}

.box {
/* 整个父盒子区域都可以点,是个小手 */
cursor: pointer;
/* 过渡动画时间,主要是按下缩小一圈 */
transition: transform 0.2s;
position: relative;
}
.box input {
/* 去除默认复选框样式 */
appearance: none;
/* 中间滑动圆圈的宽高,简单白色背景 */
width: 20vmin;
height: 20vmin;
border-radius: 50%;
background-color: #ffffff;
/* 灰色阴影 */
box-shadow: 0 0.5vmin 2vmin rgba(0, 0, 0, 0.2);
/* 鼠标小手 */
cursor: pointer;
}

.box svg {
/* 中间心形图标的宽高,撑开整个开关区域 */
width: 40vmin;
height: 30vmin;
/* background-color: skyblue; */

/* 中间填充颜色 */
fill: #ffffff;
/* 描边颜色,描边头是圆润的 */
stroke: #d6d6ee;
stroke-linejoin: round;

}



  • 开关动画


 @keyframes animate-on {

/* 动画就是简单的位置变换,要根据情况调整 */
0% {
top: 2.5vmin;
left: 1.5vmin;
}

25% {
top: 5.5vmin;
left: 5vmin;
}

50% {
top: 7vmin;
left: 10vmin;
/* 到正中间时圆大一小圈 */
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 15vmin;
}

100% {
top: 2.5vmin;
left: 18.5vmin;
}
}

@keyframes animate-off {

/* 关闭的动画就是反着来 */
0% {
top: 2.5vmin;
left: 18.5vmin;
}

25% {
top: 5.5vmin;
left: 15vmin;
}

50% {
top: 7vmin;
left: 10vmin;
transform: scale(1.05);
}

75% {
top: 5.5vmin;
left: 5vmin;
}

100% {
top: 2.5vmin;
left: 1.5vmin;
}
}

细节:开关按钮的小球到中间时要变大一点点,因为爱心之间位置比较大一点,这样滑动起来才好看


完整代码


code.juejin.cn/pen/7173909…


结尾


朋友收到代码后连连道谢,还非要请我周末去吃个烤🐏腰子补补,哎!!盛情难却,勉为其难的去吃吧,声明:我可不是为了那🐏腰子去的啊!主要是人家盛情邀请,咱们没办法拒绝😁。如果代码中有任何错误欢迎大家指正,相互学习相互进步


作者:一骑绝尘蛙
来源:juejin.cn/post/7173940249440026631
收起阅读 »

面试官:什么是JWT?为什么要用JWT?

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT? 1.什么是 JWT? JWT(JSON Web Token)是一种开放标准(RF...
继续阅读 »

目前传统的后台管理系统,以及不使用第三方登录的系统,使用 JWT 技术的还是挺多的,因此在面试中被问到的频率也比较高,所以今天我们就来看一下:什么是 JWT?为什么要用 JWT?


1.什么是 JWT?


JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络上安全传输信息的简洁、自包含的方式。它通常被用于身份验证和授权机制。
JWT 由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。



  1. 头部(Header):包含了关于生成该 JWT 的信息以及所使用的算法类型。

  2. 载荷(Payload):包含了要传递的数据,例如身份信息和其他附属数据。JWT 官方规定了 7 个字段,可供使用:

    1. iss (Issuer):签发者。

    2. sub (Subject):主题。

    3. aud (Audience):接收者。

    4. exp (Expiration time):过期时间。

    5. nbf (Not Before):生效时间。

    6. iat (Issued At):签发时间。

    7. jti (JWT ID):编号。



  3. 签名(Signature):使用密钥对头部和载荷进行签名,以验证其完整性。



JWT 官网:jwt.io/



2.为什么要用 JWT?


JWT 相较于传统的基于会话(Session)的认证机制,具有以下优势:



  1. 无需服务器存储状态:传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等。而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展。

  2. 跨域支持:由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权。

  3. 适应微服务架构:在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性。使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权。

  4. 自包含:JWT 包含了认证和授权信息,以及其他自定义的声明,这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制。

  5. 扩展性:JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高。


总结来说,使用 JWT 相较于传统的基于会话的认证机制,可以减少服务器存储开销和管理复杂性,实现跨域支持和水平扩展,并且更适应无状态和微服务架构。


3.JWT 基本使用


在 Java 开发中,可以借助 JWT 工具类来方便的操作 JWT,例如 HuTool 框架中的 JWTUtil。


HuTool 介绍:doc.hutool.cn/pages/JWTUt…


使用 HuTool 操作 JWT 的步骤如下:



  1. 添加 HuTool 框架依赖

  2. 生成 Token

  3. 验证和解析 Token


3.1 添加 HuTool 框架依赖


在 pom.xml 中添加以下信息:


<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.8.16version>
dependency>

3.2 生成 Token


Map map = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", Integer.parseInt("123")); // 用户ID
put("expire_time", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15); // 过期时间15天
}
};
JWTUtil.createToken(map, "服务器端秘钥".getBytes());

3.3 验证和解析 Token


验证 Token 的示例代码如下:


String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2MjQwMDQ4MjIsInVzZXJJZCI6MSwiYXV0aG9yaXRpZXMiOlsiUk9MRV_op5LoibLkuozlj7ciLCJzeXNfbWVudV8xIiwiUk9MRV_op5LoibLkuIDlj7ciLCJzeXNfbWVudV8yIl0sImp0aSI6ImQ0YzVlYjgwLTA5ZTctNGU0ZC1hZTg3LTVkNGI5M2FhNmFiNiIsImNsaWVudF9pZCI6ImhhbmR5LXNob3AifQ.aixF1eKlAKS_k3ynFnStE7-IRGiD5YaqznvK2xEjBew";
JWTUtil.verify(token, "123456".getBytes());

解析 Token 的示例代码如下:


String rightToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWUsIm5hbWUiOiJsb29seSJ9.U2aQkC2THYV9L0fTN-yBBI7gmo5xhmvMhATtu8v0zEA";
final JWT jwt = JWTUtil.parseToken(rightToken);
jwt.getHeader(JWTHeader.TYPE);
jwt.getPayload("sub");

3.4 代码实战


在登录成功之后,生成 Token 的示例代码如下:


// 登录成功,使用 JWT 生成 Token
Map payload = new HashMap() {
private static final long serialVersionUID = 1L;
{
put("uid", userinfo.getUid());
put("manager", userinfo.getManager());
// JWT 过期时间为 15 天
put("exp", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15);
}
};
String token = JWTUtil.createToken(payload, AppVariable.JWT_KEY.getBytes());

例如在 Spring Cloud Gateway 网关中验证 Token 的实现代码如下:


import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import com.example.common.AppVariable;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
* 登录过滤器(登录判断)
*/

@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 排除登录验证的 URL 地址
private String[] skipAuthUrls = {"/user/add", "/user/login"};

@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 当前请求的 URL
String url = exchange.getRequest().getURI().getPath();
for (String item : skipAuthUrls) {
if (item.equals(url)) {
// 继续往下走
return chain.filter(exchange);
}
}
ServerHttpResponse response = exchange.getResponse();
// 登录判断
List tokens =
exchange.getRequest().getHeaders().get(AppVariable.TOKEN_KEY);
if (tokens == null || tokens.size() == 0) {
// 当前未登录
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token 有值
String token = tokens.get(0);
// JWT 效验 token 是否有效
boolean result = false;
try {
result = JWTUtil.verify(token, AppVariable.JWT_KEY.getBytes());
} catch (Exception e) {
result = false;
}
if (!result) {
// 无效 token
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
} else { // 判断 token 是否过期
final JWT jwt = JWTUtil.parseToken(token);
// 得到过期时间
Object expObj = jwt.getPayload("exp");
if (expObj == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
long exp = Long.parseLong(expObj.toString());
if (System.currentTimeMillis() > exp) {
// token 过期
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
return chain.filter(exchange);
}

@Override
public int getOrder() {
// 值越小越早执行
return 1;
}
}

4.实现原理分析


JWT 本质是将秘钥存放在服务器端,并通过某种加密手段进行加密和验证的机制。加密签名=某加密算法(header+payload+服务器端私钥),因为服务端私钥别人不能获取,所以 JWT 能保证自身其安全性。


小结


JWT 相比与传统的 Session 会话机制,具备无状态性(无需服务器端存储会话信息),并且它更加灵活、更适合微服务环境下的登录和授权判断。JWT 是由三部分组成的:Header(头部)、Payload(数据载荷)和 Signature(签名)。


作者:Java中文社群
来源:juejin.cn/post/7309310129024008230
收起阅读 »

产品经理:能不能根据用户心情自动切换主题。我:好的。

web
效果展示 在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。 代码仓库地址:github.com/dbfu/antd-p… 前言 这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button...
继续阅读 »

效果展示


17.gif


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限,不支持手机浏览器。


代码仓库地址:github.com/dbfu/antd-p…


前言


这个灵感来自于zxg_神说要有光大佬的写一个可以当镜子照的Button这篇文章,正好今天看了一个人脸识别的前端仓库,可以动态识别人的表情,本来想写一个“根据用户心情变色的按钮”,同事说能不能实现”根据用户心情自动切换系统主题“,我想了一下好像可以的。


实现思路


借助第三方库透过摄像头事实获取用户的表情,然后根据表情动态切换主题。


具体实现


先使用antd pro脚手架初始化一个antd pro项目


pro create antd-pro-expression-theme

安装face-api.js


pnpm i face-api.js

到仓库中下载源码,把weights文件夹复制到antd pro项目中的public文件夹下,这一步很关键,我被这个地方卡了一段时间。


改造antd pro项目,支持动态主题。


在src目录下创建expression.tsx标题组件


import { useEffect, useRef, useState } from 'react';
import * as faceapi from 'face-api.js';


const expressionMap: any = {
"neutral": '正常',
"happy": '开心',
"sad": '悲伤',
"surprised": '惊讶',
}

const Hidden = true;

function getExpressionResult(expression: any) {
if (!expression) return;
const keys = [
'neutral',
'happy',
'sad',
'angry',
'fearful',
'disgusted',
'surprised',
];

const curExpression = keys.reduce((prev: string, cur: string) => {
if (!prev) {
return cur;
} else {
return expression[cur] > expression[prev] ? cur : prev;
}
}, '');
return curExpression;
}

export function Expression({
onExpressionChange,
}:
any
) {

const videoRef = useRef<HTMLVideoElement>(null);
const [expression, setExpression] = useState<string | undefined>('');

useEffect(() => {
if (onExpressionChange) {
onExpressionChange(expression);
}
}, [expression]);

async function run() {
await faceapi.nets.tinyFaceDetector.load('/widgets/');

await faceapi.loadSsdMobilenetv1Model('/widgets/');
await faceapi.loadFaceLandmarkModel('/widgets/');
await faceapi.loadFaceExpressionModel('/widgets/');

const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
}

useEffect(() => {
run();
}, []);

async function onPlay(): Promise<any> {

const videoEl = videoRef.current;

if (!videoEl) return;

if (videoEl.paused || videoEl.ended) return setTimeout(() => onPlay());

const result = await faceapi
.detectSingleFace(videoEl)
.withFaceExpressions();

setExpression(getExpressionResult(result?.expressions))

setTimeout(() => onPlay());
}

return (
<div style={{ opacity: Hidden ? 0 : 1 }} >
<video
style={{
background: '#fff',
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: Hidden ? 0 : 10001
}}
onLoadedMetadata={() =>
{ onPlay() }}
id="inputVideo"
autoPlay
muted
playsInline
ref={videoRef}
/>
<div
style={{
opacity: 1,
width: 640,
height: 480,
position: 'fixed',
top: 0,
left: 0,
zIndex: 10001,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>

{expressionMap?.[expression || 'neutral']}
div>

div>
)
}

这样就简单的获取到了表情,目前我就支持了正常、开心、惊讶、伤心四种表情,实际上他还支持其他一些表情,大家可以自己去体验一下。我主要参考了这个demo,这里面还有其他demo大家可以去体验一下。
如果不想显示视频,把上Hidden变量设置为true就行了。


在src目录下创建theme-provider.tsx文件


import { ConfigProvider } from 'antd';
import throttle from 'lodash/throttle';

import { Expression } from './expression';
import { useMemo, useState } from 'react';

export default function ThemeProvider({ children }: any) {

const [theme, setTheme] = useState<string>('');

const expressionChange = useMemo(
() => throttle((expression: string) => {
const map: any = {
happy: 'rgb(245, 34, 45)',
sad: 'rgb(192, 192, 192)',
surprised: 'rgb(250, 173, 20)',
};

setTheme(map[expression] ? map[expression] : 'rgb(22, 119, 255)')
}, 1000), [])


return (
<ConfigProvider theme={{
token: {
colorPrimary: theme || 'rgb(22, 119, 255)',
}
}}>

<Expression onExpressionChange={expressionChange} />
{children}
ConfigProvider>

)
}

这个文件用来监听表情变化,然后动态设置主题,目前也是只支持了正常、开心、惊讶、伤心四种主题。


最后在src/app.tsx使用theme-provider组件,并删除下面截图中的代码,不然我们的主题会被默认主题覆盖掉,导致不能改主题。


export function rootContainer(container: any) {
return React.createElement(ThemeProvider, null, container);
}

image.png


然后启动项目就行了。第一次获取表情有点慢,可能要等一会。


总结


这个功能看似没用,实则真没用,主要是想整个活让大家乐一下。大家应该还记得以前有个比较热门的话题吧,根据手机壳改变主题颜色,如果能通过摄像头获取到手机壳的颜色,好像也不是不行🐶。


在线体验地址:dbfu.github.io/antd-pro-ex…,需要开启摄像头权限。


代码仓库地址:github.com/dbfu/antd-p…


作者:前端小付
来源:juejin.cn/post/7226385396167704634
收起阅读 »

实现抖音“刚刚看过”的功能(原生js的写法)

web
先上一下效果图吧 点击一下刚刚看过的按钮就会滚动到视频的位置 实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题 比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把...
继续阅读 »

先上一下效果图吧


点击一下刚刚看过的按钮就会滚动到视频的位置
image.png


image.png


实现这个效果,如果不考虑效率问题肯定是非常简单,但是我们就是要考虑这个传输效率的问题


比方说这个主页有2000条视频,但是目前看的视频在第1900个,那需要滑到这第1900个视频的位置,不可能把之前所有的视频都加载出来吧,这样子的话这效率太低了吧,传输量加上请求,怎么可能吃得消
所以这个时候我们只需要创建好元素,但是不需要向服务器要这1900个视频的内容,我只要创建好元素,就可以滑动到这个视频的位置了,那要怎么加载这视频的内容呢?那就是判断用户看到哪一块,看到哪,我们加载到哪,类似于懒加载的效果


所以我这里提供一个思路,最主要的就是两个关键函数(createELement,loadPages)


createElement(page)的作用就是传入页码,他就会创建好这页面加上之前所有的元素,这个函数只管创建好元素,内容不归他管,内容等到后面在进行加载


loadPages()这个函数就是根据用户当前能看到第几页,那么就把第几页的内容加载出来,看到哪就加载哪个页面的数据,这里还需要考虑到两个页面重叠,都需要加载出来


那么首先来准备好html


<div class="contain"></div> //放置内容的盒子
<div class="btn"> //刚刚看过的按钮
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

当然css样式也是要准备好的,可以根据自己公司的UI设计图来写


body {
background-color: #000;
padding: 100px 300px;
}

.contain {
width: 100%;
height: 100%;
display: grid; //宫格布局
grid-template-columns: repeat(5, 1fr);
grid-column-gap: 50px; //每一列的间距
grid-row-gap: 80px; //每一行的间距
}
.item {
width: 200px;
height: 300px;
border: 1px solid #fff;
}
.playing {
width: 200px;
height: 300px;
position: relative;
}
.playing img {
filter: blur(3px);
-webkit-filter: blur(3px);
}
.playing::after {
content: "播放中";
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 300px;
font-size: 20px;
font-weight: bold;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.btn {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
button {
font-size: 16px;
position: relative;
margin: auto;
padding: 1em 2.5em 1em 2.5em;
border: none;
background: #fff;
transition: all 0.1s linear;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.1);
}
button:hover {
cursor: pointer;
}
button:active {
transform: scale(0.95);
}

button span {
color: #464646;
}

button .border {
position: absolute;
border: 0.15em solid #fff;
transition: all 0.3s 0.08s linear;
top: 50%;
left: 50%;
width: 9em;
height: 3em;
transform: translate(-50%, -50%);
}

button:hover .border {
display: block;
width: 9.9em;
height: 3.7em;
}

.full-rounded {
border-radius: 2em;
}

这些都不是最重要的


还有一些工具函数


1.getOffset(id) 来获取当前视频前面有多少个视频


这个根据实际情况来做,正常情况这里是向服务端获取的,我这里就模拟了一下请求


// 传入当前视频的id就可以获取之前有多少个视频
function getOffset(id) {
return new Promise((res, rej) => {
let result = id - 1;
res(result);
});
}

2.getVideo(page,size)


获取页面的资源
同样这里也是向服务端发请求获取的,我这里也是自己模拟


// 传入页码和每页多少条,即可获取图片数据
function getVideo(page, size) {
return new Promise((res) => {
let arr = [];
// 上一页有多少个,从哪开始num
let num = (page - 1) * size;
for (let i = 0; i < size; i++) {
let obj = {
id: num + i,
cover: `https://picsum.photos/200/300?id=${num + i}`,
};
arr.push(obj);
}
res(arr);
});
}

3.getIndexRange(page,size)


获取这个页码的最小索引和最大索引


// 传入页码和大小算出这个页码的起始和结束下标
function getIndexRange(page, size) {
let start = (page - 1) * size;
let end = start + size - 1;
return [start, end];
}

4.debounce(fn,deplay=300)


这个就是防抖啦,让loadpage函数不要执行太多次,节省性能


function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}

5.getPage(index,size)


传入当前视频的下标和页面大小,返回当前视频在第几页


function getPage(index, size) {
return Math.ceil((index + 1) / size);
}

以上都是工具函数


准备工作


1.定义好一页需要多少元素


const SIZE = 15;
// 刚刚看过视频的id
const currentId = 200;
// 页码
let i = 1;

2.获取页面两个重点元素


let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");

现在来写最重要的函数


1.createElement(page)


传入页码即可创建好这个页面包括之前的所有元素

步骤:

1.算出需要创建多少元素page*size

2.创建item添加到contain元素的children中

3.给每个item添加侦查器,判断是否出现在视口内


function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item); //侦查器,判断是否出现在视口内
}
}

2.视口观察器


const visibleIndex = new Set(); //全局创建一个不重复的集合
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
//isIntersecting为true就代表在视口内
if (e.isIntersecting) {
visibleIndex.add(index);
} else {
visibleIndex.delete(index);
}
}
debounceLoadPage();// 防抖后的loadpage
});

3.获取集合的最大及最小的索引


function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

4.加载视口内的元素的资源


      function loadPage() {
// 得到当前能看到的元素索引范围
const [minIndex, maxIndex] = getRange();
const pages = new Set(); // 不重复的页码集合
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));// 遍历将侦查器集合范围内的所在页面都加入到pages的集合内
}
// 遍历页码集合
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);//获取页码的索引范围
if (contain.children[minIndex].dataset.loaded) { //如果页码最小索引的元素有自定义属性就跳过,代表加载过
continue;
}
contain.children[minIndex].dataset.loaded = true;//如果没有就代表没有加载过,添加上自定义属性
//将当前页码传给获取资源的函数
getVideo(page, SIZE).then((res) => {
//拿到当前页面需要的数据数组,遍历渲染到页面上
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数,将loadpage函数防抖
const debounceLoadPage = debounce(loadPage, 300);

5.判断刚刚看过的按钮是否显示


// 页面进来就需要触发获取当前视频之前有多少个视频,判断按钮是否显示
      async function setVisible() {
        // 获取之前有多少个视频
        let offest = await getOffset(currentId);
        let [minIndex, maxIndex] = getRange();

        // 返回告诉你第几页
        const page = getPage(offest, SIZE);
        if (offest >= minIndex && offest <= maxIndex) {
          btn.style.display = "none";
        } else {
          btn.style.display = "block";
        }
        btn.dataset.page = page;
        btn.dataset.index = offest;
      }

6.给按钮添加点击事件,滚动到指定位置


btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page; // 跳转将页码更新
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

7.给window添加滚动事件,页面触底页码加一


window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++); //页面触底就页码加一
}
});

完整代码


<body>
<div class="contain"></div>
<div class="btn">
<button class="full-rounded">
<span>刚刚看过</span>
<div class="border full-rounded"></div>
</button>
</div>

<script src="./api.js"></script>
<script src="./index.js"></script>
<script>
const SIZE = 15;
let contain = document.querySelector(".contain");
let btn = document.querySelector(".btn");
// 页码
let i = 1;

const visibleIndex = new Set();

// 视口观察器
let ob = new IntersectionObserver((entries) => {
for (const e of entries) {
const index = e.target.dataset.index;
if (e.isIntersecting) {
// 将在视口内的元素添加到集合内
visibleIndex.add(index);
} else {
// 将不在视口内的元素从集合内删除
visibleIndex.delete(index);
}
}
debounceLoadPage();
});

function getRange() {
if (visibleIndex.size === 0) return [0, 0];
const max = Math.max(...visibleIndex);
const min = Math.min(...visibleIndex);
return [min, max];
}

// 创建元素
function createElement(page) {
// 防止一页重复创建
const childLen = contain.children.length;
const count = page * SIZE - childLen;
for (let i = 0; i < count; i++) {
const item = document.createElement("div");
item.className = "item";
item.dataset.index = i + childLen;
contain.appendChild(item);
ob.observe(item);
}
}

// 得到当前能看到的元素索引范围
function loadPage() {
const [minIndex, maxIndex] = getRange();
const pages = new Set();
for (let i = minIndex; i <= maxIndex; i++) {
pages.add(getPage(i, SIZE));
}
for (const page of pages) {
const [minIndex, maxIndex] = getIndexRange(page, SIZE);
if (contain.children[minIndex].dataset.loaded) {
continue;
}
contain.children[minIndex].dataset.loaded = true;
getVideo(page, SIZE).then((res) => {
for (let i = minIndex; i < maxIndex; i++) {
const item = contain.children[i];
item.innerHTML = `<img src="${res[i - minIndex].cover}" alt="">`;
}
});
}
}

// 创建防抖加载函数
const debounceLoadPage = debounce(loadPage, 300);

// 刚刚看过视频的id
const currentId = 200;

// 页面进来就需要触发获取之前有多少个视频,判断按钮是否显示
async function setVisible() {
// 获取之前有多少个视频
let offest = await getOffset(currentId);
let [minIndex, maxIndex] = getRange();
// 返回告诉你第几页
const page = getPage(offest, SIZE);
if (offest >= minIndex && offest <= maxIndex) {
btn.style.display = "none";
} else {
btn.style.display = "block";
}
btn.dataset.page = page;
btn.dataset.index = offest;
}

btn.onclick = () => {
const page = +btn.dataset.page;
const index = +btn.dataset.index;
i = page;
createElement(page);
contain.children[index].scrollIntoView({
behavior: "smooth",
block: "center",
});
contain.children[index].classList.add("playing");
btn.style.display = "none";
};

window.addEventListener("scroll", () => {
//窗口高度
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight;
//滚动高度
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
//页面高度
var documentHeight =
document.documentElement.scrollHeight || document.body.scrollHeight;

if (windowHeight + scrollTop == documentHeight) {
createElement(i++);
}
});
createElement(i);
setVisible();
</script>
</body>


🔥🔥🔥🔥🔥🔥到这里就实现了抖音的刚刚看过的功能!!!!!🔥🔥🔥🔥🔥🔥🔥🔥🔥


作者:井川不擦
来源:juejin.cn/post/7257441472445644855
收起阅读 »

Swiper,一款超赞的 JavaScript 滑动库?

web
嗨,大家好,欢迎来到猿镇,我是镇长,lee。 又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - Swiper。Swiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。 git...
继续阅读 »

嗨,大家好,欢迎来到猿镇,我是镇长,lee。


又到了和大家见面的时间,今天分享一款 JavaScript 滑动库 - SwiperSwiper 不仅是一个简单的滑动库,更是一个全面的滑动解决方案,让你轻松创建出各种炫酷的滑动效果。


github.com/nolimits4we…


什么是Swiper?


Swiper 是一个基于现代触摸滑动的 Javascript 库,用于创建轮播、幻灯片以及任何需要滑动的网页组件。它的灵活性和强大功能使得开发者能够实现各种复杂的滑动效果,而不需要深入了解复杂的滑动原理。


为什么选择Swiper?



  • 易于使用:  Swiper 提供了简单易懂的 API 和文档,使得即便是初学者也能轻松上手。只需几行代码,你就可以创建一个漂亮的轮播。

  • 跨平台兼容:  Swiper 支持多平台,包括PC、移动端和平板电脑,确保你的滑动效果在各种设备上都能够流畅运行。

  • 丰富的配置选项:  你可以根据自己的需求定制 Swiper 的各种参数,如滑动速度、自动播放、循环模式等,满足不同场景的需求。


如何开始使用Swiper?


步骤1:引入Swiper


首先,你需要在你的项目中引入 Swiper 库。你可以选择使用 CDN,也可以通过 npm 或 yarn 进行安装。



<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css"
/>


<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js">script>

步骤2:创建HTML结构


创建一个包裹你滑动内容的容器,并添加滑动项。


<div class="swiper">
      <div class="swiper-wrapper">
        <div class="swiper-slide">Slide 1div>
        <div class="swiper-slide">Slide 2div>
        <div class="swiper-slide">Slide 3div>
        
      div>
      
      <div class="swiper-pagination">div>

      
      <div class="swiper-button-prev">div>
      <div class="swiper-button-next">div>
    div>

设置样式


.swiper {
      width600px,
    height: 300px;
}
.swiper-slide {
    background-color: red; // 设置背景色方便查看效果
}

步骤3:初始化Swiper


使用 Javascript 初始化 Swiper,并传入配置选项。


var mySwiper = new Swiper('.swiper-container', {
  // 配置项
  // 可选参数
  looptrue,

  // 分页器
  pagination: {
    el'.swiper-pagination',
  },

  // 导航箭头
  navigation: {
    nextEl'.swiper-button-next',
    prevEl'.swiper-button-prev',
  },
});

步骤4:享受滑动的乐趣


你已经成功集成了 Swiper,现在你可以在网页上看到炫丽的滑动效果了。


1.gif


进阶用法


Swiper 提供了许多高级用法和定制选项,以适应各种复杂的需求。以下是一些Swiper的高级用法:


1. 自定义动画和过渡效果


通过使用 Swiper 的effect属性,你可以指定不同的过渡效果,例如 "slide"、"fade"、"cube"等。这可以为你的滑动项添加独特的动画效果。


var mySwiper = new Swiper('.swiper-container', {
  effect'cube',
  cubeEffect: {
    slideShadowsfalse,
    shadowfalse,
  },
});

2. 动态添加或删除滑动项


通过 Swiper 的API,你可以在运行时动态地添加或删除滑动项。这在需要根据用户操作或数据变化来更新滑动项时非常有用。


// 添加新的滑动项
mySwiper.addSlide(0'New Slide
');

// 删除指定索引的滑动项
mySwiper.removeSlide(1);

3. 深度定制分页器和导航按钮


入门示例中简单引入了分页器,Swiper 的分页器和导航按钮可以进行高度的自定义。你可以通过自定义HTML、样式和事件来实现自己想要的分页器和导航按钮效果。


var mySwiper = new Swiper('.swiper-container', {
  pagination: {
    el'.swiper-pagination',
    clickabletrue,
    renderBulletfunction (index, className) {
      return ' + className + '">' + (index + 1) + '';
    },
  },
  
navigation: {
    
nextEl'.swiper-button-next',
    
prevEl'.swiper-button-prev',
  },
});

4. 使用Swiper插件


Swiper 支持插件系统,你可以使用一些第三方插件来增强 Swiper 的功能,例如 Swiper 的滚动条插件、懒加载插件等。通过导入并配置插件,你可以轻松地扩展 Swiper 的能力。


// 导入并使用懒加载插件
import SwiperCore, { Lazy } from 'swiper/core';
SwiperCore.use([Lazy]);

var mySwiper = new Swiper('.swiper-container', {
  // 启用懒加载
  lazytrue,
});

swiperjs.com/plugins


5.gif


5. 响应式设计


Swiper 允许你根据不同的屏幕尺寸设置不同的配置选项,实现响应式设计。这样,你可以在不同设备上提供最佳的用户体验。


var mySwiper = new Swiper('.swiper-container', {
  slidesPerView: 3,
  spaceBetween: 30,
  breakpoints: {
    // 当窗口宽度小于等于 768 像素时
    768: {
      slidesPerView: 2,
      spaceBetween: 20,
    },
    // 当窗口宽度小于等于 480 像素时
    480: {
      slidesPerView: 1,
      spaceBetween: 10,
    },
  },
});

这些高级用法展示了 Swiper 库的强大功能和灵活性,深入了解这些特性将使你能够更好地适应各种项目需求。


示例演示


2.gif


3.gif


4.gif


结语


通过 Swiper,你可以轻松实现网页上的各种滑动效果,为用户提供更加出色的交互体验。它的简单易用性和丰富的功能使其成为前端开发中不可或缺的利器。不论你是新手还是有经验的开发者,都值得深入了解 Swiper ,为你的网页增添一份技术的魔法。


更多


今天的分享就到这里,如果觉得对你有帮助,感谢点赞、分享、关注一波,你的认可是我创造的最大动力。


作者:繁华落尽丶lee
来源:juejin.cn/post/7309061655094575139
收起阅读 »

2023年终总结(被优化,外企工作,订婚,结婚)

前言 先介绍一下本人的自身的情况,双非本科,文科出身,2021年10月开始前端开发。 2022年3月14跳槽一家智能机器人公司。 2023年2月14日入职外企。 工作 又到了一年一度年终总结的时候,2023年对我来说是充满挑战和成长的一年。在这一年里,我经历了...
继续阅读 »

前言


先介绍一下本人的自身的情况,双非本科,文科出身,2021年10月开始前端开发。

2022年3月14跳槽一家智能机器人公司。

2023年2月14日入职外企。


工作


又到了一年一度年终总结的时候,2023年对我来说是充满挑战和成长的一年。在这一年里,我经历了许多变化,也取得了许多收获。以下是我对2023年的个人年终总结。
话不多说,先上图,看一下我去年的目标。
image.png


去年制定目标的时候从生活,工作两方面立了flag,那就分开仔细来说一下吧。


被优化


我记得清清楚楚2023年1月13日公司降本增效,在做的项目整个被砍,不过好在公司要上市,名声很重要,赔偿了2个月薪资让大家主动离职。当时临近新年只有一周,工作不好找,只能提前回家过年。

我记得那天超级冷,我一手抱着午睡时候的小猪,一手拿着靠垫,还用胳膊拎着装的满满的帆布袋,拿着小风扇,徒步走到地铁站,东西太多,上电梯的时候一个没拿住全都掉地上了。

回到家之后,感觉心里空落落的,放下东西抱着被子哇哇哭,可能是天太冷冻得,也可能是东西太多拿不动累的。


找到新工作


虽然没有工作,但我可是大年初六就来北京了呢,利用年前一周和新年在家的时间,每天保持8小时的学习,复习知识点,刷题,刷算法,默默的告诉自己一定要进大厂,找份工资高的工作。但是后来我退缩了,我甚至不敢投大厂的简历。大年初十开始第一家面试,是一家新能源公司,年前约的,但是由于是第一家面试,自己答的并不好,那结果显而易见,过不了。十一,十二没有约到面试,男朋友劝我说很多公司还没上班呢,告诉我不要慌,不过后来陆陆续续的每天大概两家公司的面试。面试期间继续保持着学习,也刷着各大招聘软件,加上一些朋友的内推。就这样,经历了两周的面试,2023年2月14日入职一家外企。(嘿嘿,顺便提一嘴,好好学英语。)


解答一下大家关于外企的疑问


1.     外企需要英语吗?

当然,不会英语怎么和国外的同事交流,总不能别人说什么你不懂,你想表达想法用中文吧,人家老外不懂中文。


2.     外企工资高吗?

我觉得工资还好,达到了我的期望薪资。我目前的公司大部分是中国人,但是组件库和重要项目的开发都在国外,回到第一个问题,涉及到组件库的问题就要和国外同事进行交流了。

3.     外企加班吗?

我目前加班很少,都是自愿主动加班的,不超过一小时,一个月加班时长不超8小时。

4.     外企福利待遇怎么样?

福利待遇超级棒,各种京东卡,礼品,下午茶,加班半小时就有的加班餐。今年双十一,感恩节都发了京东卡。

5.请假好请吗?

请假很好请。和领导说一声群里报备一下即可。

大家还有什么关于外企的疑问,欢迎评论


存款


去年定的存款目标没有完成,差了点,一些事情花掉了一些,明年继续加油。

现如今的经济环境,谁知道程序员最后的归宿是什么,好好存钱就是了。😊


生活


订婚


和男朋友在一起四年啦,感情到位,三观契合,父母支持,所以我们商量着在今年5月1号订婚,男朋友亲戚加上我这边的亲戚朋友简单的办了一场订婚宴。


出省旅游


今年事情比较多,没有去太远的地方玩,只去天津玩了一天。超级推荐天津海洋博物馆,超级出片的。



婚纱照


结婚前当然要拍婚纱照啦,想着北京一万拍出来也不一定好看,就选择回老家了,3000的套餐,40张精修,五套服装三内两外,划算又好看。



婚礼


2023年10月1日举办了婚礼,当天我盛装出席,迎接美好的生活的下一阶段。


在这个互联网上充斥着恐婚恐育的观念,离婚率居高不下的今天,或许站在婚姻的城前,你也踌躇不前或者悔不当初,但是就像我们常说的爱情是一场双向奔赴,其实婚姻何尝也不是一种相互包容呢。我们无需羡慕那个“从前车马慢,一生只爱一人。”的时代,只要我们去懂得看到对方的优点,认真经营自己的婚姻,其实每个人都可以找到那个可以携手一生的人。
现在已经结婚两个多月了,虽然身份转变了,但是好像和婚前也没什么大的区别,还是继续上班下班,期待周末的到来。



明年目标


1.存款达到xx(w)。


如何实现:
理性消费:注意自己的消费习惯,避免不必要的购物和娱乐消费。

定期存款:工资发下来留下当月的生活费,剩下的全部存起来。

兼职:有时间的话研究一下副业,做一些兼职。


2.过BEC中级


b站Bec中级视频:跟着b站视频先把考试内容和学习方法过一遍,每天保证一小时的英语学习时间。

单词:每天背20个单词,一定记得复习,不然第二天就忘了。

听力:听力多听多练,目前也没找到啥好的方法。


3.出省旅游两次


新疆:想去一趟新疆,感受一下那里的文化。

三亚:喜欢海,还想去免税店购物。


4.条件允许的事情下领证


没有领证主要是刚到新公司一年,休婚假不太好,加上职场对已婚女性不太友好,有点不敢领。


2024年也要继续加油呀!!!😊😊😊,新的一年里继续保持学习和成长的姿态,不断探索新的领域和挑战自己的能力极限。


作者:zhouzhouya
来源:juejin.cn/post/7309158128700424242
收起阅读 »

你的代码凌晨两点在干什么

如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。 前两天跟朋友讨论技术,他说他们的服务器从凌晨零点开始,就开始跑各种各样的定时任务,基本上能跑到早晨5、6点钟。因为他们的业务属于访问量不大,但是数据量非常大,而且每天的...
继续阅读 »

如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。


前两天跟朋友讨论技术,他说他们的服务器从凌晨零点开始,就开始跑各种各样的定时任务,基本上能跑到早晨5、6点钟。因为他们的业务属于访问量不大,但是数据量非常大,而且每天的数据要根据一些规则重新计算,所以就每天这么跑着。一到夜里,服务器负载比白天还高。


如果服务器上有灯光根据负载高低进行闪烁,那到了夜里,一定会看到他们的服务器唰唰的闪着金光。



说到这儿,我想到了之前的一件事儿。


有一天上午到公司不久,运维的同事悠悠的走过来,苦笑着说:“你们的代码凌晨两点在干什么,服务器都差点搞挂了”。


原来是因为一个定时任务(也是计算型的任务)开的线程太多了,之前由于计算量比较少,很快就结束了。那天由于业务调整,数据量一下子大了很多,线程又开的过多了,导致长时间负载过高,直接就给运维发了预警通知了。应用服务器还好,数据库服务器差点没顶住。


由于这些数据计算的时间长一点、短一点都没关系,所以后来把线程数减少了一些。


代码在凌晨到底在干什么


有一些场景是可以把定时任务放到凌晨来执行的。


夜里有一个特点,大多数的应用在夜里的流量都会比较低,也就是服务器的资源比较空闲,这个时候,正好可以将资源利用起来,执行一些逻辑。


而执行的这些逻辑有一个核心特点,那就是可以放到晚上执行,实时性要求不是很高的业务可以。


报表类统计


这个功能很常见了,不管是电商应用、社交应用等等,凡事有用户用的系统,将来一定会涉及到报表的场景。报表一般都包括对数据的总览,要出一张报表,可能会涉及到多张表,甚至多个数据库,关联的数据更是百万、千万,甚至上亿条。


那这样一来呢,如果是放在后台,用户到界面上进行实时生成的话,不仅老板不满意,测试同事还会给你提bug,说你的接口太慢了。


对于报表来说,看前一天的数据就足够了,没必要看到今天的数据, 所以放在夜里跑任务完全没问题,这时候你一条 SQL 执行1分钟、2分钟也没关系,只要不是太离谱就可以了。


数据清洗和计算


就像我那个朋友公司一样,他们的业务会涉及到大量的数据处理的工作,包括前期的数据处理,以及每天的重新计算,而且数据量很大。


这些清洗和计算也没有那么高的实时性,只要在当天跑完就可以了。但是如果你放到白天运行,就会影响到线上业务。要不然就得多弄几台单独的服务器跑,这样成本就上来了。


所以这样的场景,也可以放到夜里跑。


数据备份和同步


数据库备份、文件备份以及数据同步等任务可以在凌晨执行,这就很常识了。


补偿任务


有些业务,可能在正常运行的时候发生了异常,当然不能是主业务。一些旁路任务发生了异常,这时候,系统一般会写一条日志,记录异常发生的上下文,越详细越好,用于事后分析以及补偿操作。


等到夜里的时候,检查这种异常业务,根据异常发生时记录的上下文信息,进行二次处理。当然不能是发短信、发通知这种功能了,如果夜里给用户发短信,免不了要被投诉。


总结


几乎每一个系统都会有夜里执行的任务,这些任务的特点:



  1. 可以异步处理,不要求高的实时性,比如报表业务;

  2. 比较耗资源,比如大量计算、大容量的文件处理等;

  3. 要执行任务的服务器在夜里不能有太多正常线上业务,保证正常业务不被影响;


你们的代码在凌晨两点在干什么呢?


作者:古时的风筝
来源:juejin.cn/post/7305606652199125019
收起阅读 »

Android 实现自动滚动布局

前言 在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介...
继续阅读 »

前言


在平时的开发中,有时会碰到这样的场景,设计上布局的内容会比较紧凑,导致部分机型上某些布局中的内容显示不完全,或者在数据内容多的情况下,单行无法显示所有内容。这时如果要进行处理,无非就那几种方式:换行、折叠、缩小、截取内容、布局自动滚动等。而这里可以简单介绍下布局自动滚动的一种实现方式。


1. 布局自动滚动的思路


要实现滚动的效果,在Android中无非两种,吸附式的滚动或者顺滑式的滚动,吸附式就是类似viewpager换页的效果,如果需求上是要实现这样的效果,可以使用viewpager进行实现,这个类型比较简单,这里就不过多介绍。另一种是顺滑的,非常丝滑的缓慢移动的那种,要实现这种效果,可以使用RecyclerView或者ScrollView来实现。我这里主要使用ScrollView会简单点。


滑动的控件找到了,那要如何实现丝滑的自动滚动呢?我们都知道ScrollView能用scrollTo和scrollBy去让它滚动到某个位置,但如何去实现丝滑的效果?


这里就用到了属性动画, 我之前的文章也提到过属性动画的强大 juejin.cn/post/714419…


所以我这边会使用ScrollView和属性动画来实现这个效果


2. 最终效果


可以写个Demo来看看最终的效果


799d8a54-ed7d-4137-8a00-ad6bed2e2499.gif


这就是一个横向自动滚动的效果。


3. 代码实现


先写个接口定义自动滚动的行为


interface Autoscroll {

// 开始自动滚动
fun autoStart()

// 停止自动滚动
fun autoStop()

}

然后自定义一个View继承ScrollView,方便阅读,在代码中加了注释


// 自定义View继承HorizontalScrollView,我这里演示横向滚动的,纵向可以使用ScrollView
class HorizontalAutoscrollLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr), Autoscroll {

// 一些流程上的变量,可以自己去定义,变量多的情况也可以使用builder模式
var isLoop = true // 滚动到底后,是否循环滚动
var loopDelay = 1000L // 滚动的时间
var duration = 1000L // 每一次滚动的间隔时间

private var offset: Int = 0
val loopHandler = Handler(Looper.getMainLooper())
var isAutoStart = false

private var animator: ValueAnimator? = null

override fun autoStart() {
// 需要计算滚动距离所以要把计算得代码写在post里面,等绘制完才拿得到宽度
post {
var childView = getChildAt(0)
childView?.let {
offset = it.measuredWidth - width
}

// 判断能否滑动,这里只判断了一个方向,如果想做两个方向的话,多加一个变量就行
if (canScrollHorizontally(1)) {
animator = ValueAnimator.ofInt(0, offset)
.setDuration(duration)
// 属性动画去缓慢改变scrollview的滚动位置,抽象上也可以说改变scrollview的属性
animator?.addUpdateListener {
val currentValue = it.animatedValue as Int
scrollTo(currentValue, 0)
}
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {

}

override fun onAnimationEnd(animation: Animator) {
// 动画结束后判断是否要重复播放
if (isLoop) {
loopHandler?.postDelayed({
if (isAutoStart) {
scrollTo(0, 0)
autoStart()
}
}, loopDelay)
}
}

override fun onAnimationCancel(animation: Animator) {

}

override fun onAnimationRepeat(animation: Animator) {

}

})
animator?.start()
isAutoStart = true
}

}
}

// 动画取消
override fun autoStop() {
animator?.cancel()
isAutoStart = false
loopHandler.removeCallbacksAndMessages(null)
}

}

能看到实现这个功能,写的代码不会很多。其中主要需要注意一些点:

(1)属性动画要熟,我这里只是简单的效果,但如果你对属性动画能熟练使用的话,你还可以做到加速、减速等效果

(2)页面关闭的时候要调用autoStop去关闭动画

(3)这里是用scrollTo去实现滚动的效果,scrollBy也可以,但是写法就不是这样了


从代码可以看出没什么难点,都是比较基础的知识,比较重要的知识就是属性动画,熟练的话做这种效果的上限就很高。其他的像这里为什么用post,为什么用scrollTo,这些就是比较基础的知识,就不扩展讲了。


最后看看使用的地方,先是Demo的布局


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.kylin.testproject.HorizontalAutoscrollLayout
android:id="@+id/auto_scroll"
android:layout_width="150dp"
android:layout_height="wrap_content">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="小日本"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/a"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text="排放核废水污染海洋"
/>

<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:scaleType="fitXY"
android:src="@drawable/b"
/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:text=",必遭天谴!!"
/>

</LinearLayout>

</com.kylin.testproject.HorizontalAutoscrollLayout>

</LinearLayout>


然后在开始播放自动滚动(注意页面关闭的时候要手动停止)


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main)

val autoScroll: HorizontalAutoscrollLayout = findViewById(R.id.auto_scroll)
autoScroll.duration = 3000
autoScroll.loopDelay = 2000
autoScroll.autoStart()
}

4. 总结


代码比较简单,而且都加上了注释,所以没有其他要说明的。

前段时间太忙,所以这几个月都没时间写文章。想了一下,这个还是要坚持,如果有时间的话抽出点时间一天写一点,得保持一个常更新的状态。


作者:流浪汉kylin
来源:juejin.cn/post/7309392585679110194
收起阅读 »

这段代码目的太明显了

网友评论:@维妙伟小德:no data found@我叫程旭元叫我旭元就可以了:一个空的数据库你瞎查询啥呢@Laruence:ERROR 1045 (28000): Access denied for user ‘programmer’@浮夸先生Zz:你是想多...
继续阅读 »


网友评论:


@维妙伟小德:no data found

@我叫程旭元叫我旭元就可以了:一个空的数据库你瞎查询啥呢

@Laruence:ERROR 1045 (28000): Access denied for user ‘programmer’

@浮夸先生Zz:你是想多找一份工作么?

@你夏老师:是个女的就不错了,咋要求还这么高

作者:程序员的幽默
来源:mp.weixin.qq.com/s/JtdJBPpy-96STIe6WLYyIw
e>

收起阅读 »

没用的东西,你连个内存泄漏都排查不出来!!

web
背景 ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。 我:...
继续阅读 »

背景



  • ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。

  • 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。

  • 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。

  • 我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。

  • 领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?

  • 我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。

  • 领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。

  • 艹!你早这么说不就好了。





开始学习


Chrome devTools查看内存情况




  • 打开Chrome的无痕模式,这样做的目的是为了屏蔽掉Chrome插件对我们之后测试内存占用情况的影响

  • 打开开发者工具,找到Performance这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等




简单录制一下百度页面,看看我们能获得什么,如下动图所示:




从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memoryGPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点



看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况



堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录




如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为33.7MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)





在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放



在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题


首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:



内存泄漏的场景



  • 闭包使用不当引起内存泄漏

  • 全局变量

  • 分离的DOM节点

  • 控制台的打印

  • 遗忘的定时器


1. 闭包使用不当引起内存泄漏


使用PerformanceMemory来查看一下闭包导致的内存泄漏问题


<button onclick="myClick()">执行fn1函数button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象

let b = 3

function fn2() {
let c = [1, 2, 3]
}

fn2()

return a
}

let res = []

function myClick() {
res.
push(fn1())
}
script>


在退出fn1函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因fn1函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子



设置了一个按钮,每次执行就会将fn1函数的返回值添加到全局数组变量res中,是为了能在performacne的曲线图中看出效果,如图所示:




  • 在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题

  • 在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题

  • 首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:




  • 在我们每次点击按钮后,动态内存分配情况图上都会出现一个蓝色的柱形,并且在我们触发垃圾回收后,蓝色柱形都没变成灰色柱形,即之前分配的内存并未被清除

  • 所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用Heap snapshot来定位问题,如图所示:




  • 第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的1.1M内存空间变成了1.4M内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了


以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了


2. 全局变量


全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:


function fn1() {
// 此处变量name未被声明
name = new Array(99999999)
}

fn1()


  • 此时这种情况就会在全局自动创建一个变量name,并将一个很大的数组赋值给name,又因为是全局变量,所以该内存空间就一直不会被释放

  • 解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如


function fn1() {
'use strict';
name = new Array(99999999)
}

fn1()

3. 分离的DOM节点


假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')

btn.
addEventListener('click', function() {
root.
removeChild(child)
})
script>


该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory的快照功能来检测一下,如图所示





同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入detached,于是就会展示所有脱离了却又未被清除的节点对象



解决办法如下图所示:


<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')

btn.
addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')

root.
removeChild(child)
})

script>


改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:




结果很明显,这样处理过后就不存在内存泄漏的情况了


4. 控制台的打印


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

console.log(obj);
})
script>

我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance来验证一下




开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现JS Heap曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj都因为console.log被浏览器保存了下来并且无法被回收



接下来注释掉console.log,再来看一下结果:


<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)

// console.log(obj);
})
script>


可以看到没有打印以后,每次创建的obj都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了


其实同理 console.log也可以用Memory来进一步验证


未注释 console.log



注释掉了console.log




最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:



// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}


这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.errorconsole.infoconsole.dir等等都不要在生产环境下使用



5. 遗忘的定时器



定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:



<button>开启定时器button>
<script>

function fn1() {
let largeObj = new Array(100000)

setInterval(() => {
let myObj = largeObj
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,我们来看看其整体的内存分配情况吧:



按道理来说点击按钮执行fn1函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory来确认一次:




  • 在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量largeObj分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放

  • 那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:


<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
let index = 0

let timer = setInterval(() => {
if(index === 3) clearInterval(timer);
let myObj = largeObj
index ++
},
1000)
}

document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>

现在我们再通过performancememory来看看还不会存在内存泄漏的问题



  • performance




这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况




  • memory



这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1函数中的变量largeObj分配了内存,3s后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题



简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了setTimeoutsetInterval,其实浏览器还提供了一个API也可能就存在这样的问题,那就是requestAnimationFrame




  • 好了好了,学完了,ui妹妹我来了






  • ui妹妹:去你m的,滚远点





好了兄弟们,内存泄漏学会了吗?


作者:顾昂_
来源:juejin.cn/post/7309040097936474175
收起阅读 »

Flutter 日记APP-开篇

序言 在跟着wendux大佬的书学习flutter后,开始着手写个app进行实战。考虑到没有服务器,所以主要写工具类,无网络交互的app。之前看了《小狗钱钱》这本书,里面的梦想笔记让我印象深刻,便开始着手写一个记录自己梦想笔记的app。 App 构想 创建自...
继续阅读 »

序言


在跟着wendux大佬的书学习flutter后,开始着手写个app进行实战。考虑到没有服务器,所以主要写工具类,无网络交互的app。之前看了《小狗钱钱》这本书,里面的梦想笔记让我印象深刻,便开始着手写一个记录自己梦想笔记的app。


App 构想



  1. 创建自己的梦想

    1.1 梦想内容和描述

    1.2 梦想日记提醒时间,开启后会设置闹钟定时提醒

  2. 创建梦想日记

    2.1 日记标题和内容

    2.2 为了方便日记输入,接入苹果的文本扫描功能

    2.3 日记每天可多次添加或修改

  3. 日记走势

    3.1 根据每天记录的日记数量进行统计,展示一个charts图

  4. 设置功能

    4.1 支持日夜模式

    4.2 支持国际化语言切换


目前大概就这些后面准备持续更新日记内容,比如新增记账日记,记录每一笔开销和收入,然后统计每月的开销和收入,让自己对于自己的账目管理更加一目了然;还有行程记录,比如出行提醒,旅游日记等等。为了后面更好的兼容,在开始构建的时候会预留相应的字段。


App 三方选择



  1. get

    状态管理、国际化、皮肤管理于一体的三方库。当然还有其他功能,目前app比较简单仅使用这些。在选择的时候也在犹豫,要不要用BlocProvider,相对来说,另外两个三方要更加轻量一些,provider的侵入性也没有那么强。最后选择get是考虑到国际化管理和换肤等,使用get一步到位。比如国际化通常会用intl

  2. sqflite

    用于数据存储,把日记都保存到本地数据库进行缓存。

  3. shared_preferences
    本地轻量数据缓存,主要是用来存语言国际化等配置信息。

  4. easy_refresh

    上拉刷新,下拉加载

  5. fluttertoast

    Toast 弹窗,需要注意如果兼容其他平台(window)的话需要传入context。


剩下的就是更新库到本地,传统技艺:put get


基本上就是用了这些,可以说麻雀虽小,五脏俱全。后面会持续分享app的开发进度,和一些开发中遇到的问题。


作者:WhiteMonkey
来源:juejin.cn/post/7309158214481772553
收起阅读 »

【Flutter技术】如何识别一个应用是原生框架开发还是Flutter开发?

前言 根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。 当前,我们手机...
继续阅读 »

前言


根据Google官方的统计,截至2023年5月,已有超过100万个应用程序使用Flutter发布到数亿台设备上。国内许多知名公司也广泛的使用了Flutter技术,例如,腾讯、字节、阿里等等。Flutter也成为了最受欢迎的跨平台技术之一。


当前,我们手机上以及各大应用市场有大量的应用采用了Flutter跨平台技术框架,例如,微信、微博、闲鱼等等。由于Flutter框架出色的性能表现,可能不被大家所感知,接下来,跟大家分享几个鉴别一个APP是否使用了Flutter开发的方法。


1 - 双指滚动


一个比较方便、且非常快速的方法就是打开一个可滚动的页面(可以用闲鱼的商品详情页面测试),用双指或者三指滑动,如果滚动的速度加快,是用单指滚动的两倍或者三倍,那么这个页面基本可以确定是用Flutter开发的。大家可以打开手机上应用尝试一下,例如闲鱼商品的详情页面。


这方法的原理是源于Flutter的一个祖传BUG ---> [#11884] Scrolling with two fingers scrolls twice as fast,在Android和iOS平台都是如此的变现,因此可以用来检查应用是否使用了Flutter开发。


当笔者看到这个BUG之后,惊掉了下巴(2017年的ISSUE,2023年才被修复!),于是尝试修复了这个BUG ---> [#136708] Introduce multi-touch drag strategies for DragGestureRecognizer,该Patch引入了一个新的属性MultitouchDragStrategy,可以自定义多指滚动的行为,同时将系统默认行为修改为Android的表现,并计划很快补充iOS的行为。


image.png


该Patch当前已经合入master分支,在release到stable分支之前,通过双指滚动来鉴定是否使用Flutter开发依然是最便捷的方法 :)


2 - 显示布局边界 + dumpsys activity


该方法适用于Android手机,打开手机的“开发者模式”,在设置页面搜索“边界”,找到“显示布局边界”并打开:


drawing
drawing

对于原生开发的Android应用,可以查看到所有元素的边界,例如闲鱼首页采用原生开发:


drawing


当进入分类页面之后,除了手机SystemUI可以看见元素的边界,页面内容的元素系统是识别不到边界的:


drawing


此时,我们通过adb shell命令dumpsys activity activities,可以看到TOP的Activity是MainActivity


drawing

以上识别的原理为:Flutter projects的默认入口为MainActivity,它又继承于FlutterActivity,而它的内部实现为SurfaceView,Flutter通过canvas自绘所有控件,正因为如此,Android是无法识别FlutterView里的元素边界的。


3 - 日志


一般来说,集成了Flutter框架的应用,在log里会有相关Flutter日志的输出,例如,我们在操作 微信 的时候,logcat里会有flutter日志的输出:


Image_20231206105420.png


Image_20231206105403.png


4 - 安装包文件


我们也可以通过安装包里是否集成了Flutter相关lib来推断是否使用了Flutter框架


以Android手机为例:


1,提取apk,方法如下:
# 首先确保已经将ADB工具添加到系统路径中
$ adb devices # 查看设备列表,确认设备正常连接

#
然后使用以下命令获取APK的位置信息
$ adb shell pm path com.example.appname
package:/data/app/com.example.appname-1234567890abcdefg/base.apk

#
最后使用以下命令复制APK到计算机上指定目录
$ adb pull /data/app/com.example.appname-1234567890abcdefg/base.apk ~/Desktop/my_app.apk

提取了apk文件之后,可以通过7-zip提取解压,然后搜索‘flutter’相关文件,如果使用了Flutter框架,会有flutter相关lib文件(闲鱼APK):


image.png


image.png


4 - FlutterShark


image.png


可以在Android手机上安装FlutterShark应用,在赋予它QUERY_ALL_PACKAGES权限后,他可以展示手机中所有使用了Flutter框架的应用:


Screenshot_2023-12-06-15-03-55-64[1].png


同时,FlutterShark还支持显示某个应用所依赖的三方package:


image.png


总结


以上跟大家分享了几种识别Flutter应用的方法,如果你还知道有其它的方法,请在评论区留言吧 : )


作者长期活跃在Flutter开源社区,欢迎大家一起参与开源社区的共建,如果您也有意愿参与Flutter社区的贡献,可以与作者联系。-->GITHUB


您也许还对这些Flutter技术分享感兴趣:



作者:xubaolin
来源:juejin.cn/post/7309065017191088143
收起阅读 »

反钓鱼防盗号,共筑校园安全防线!Coremail出席CERNET学术年会

11月27日-30日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会在福州隆重举办,Coremail受邀出席,就高校数字化及网络安全等相关话题与高校老师、行业专家进行广泛交流。△11月27-30日,Coremail在会场设展,为嘉宾介绍邮件新技术...
继续阅读 »

11月27日-30日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会在福州隆重举办,Coremail受邀出席,就高校数字化及网络安全等相关话题与高校老师、行业专家进行广泛交流。

△11月27-30日,Coremail在会场设展,为嘉宾介绍邮件新技术、安全新态势。

11月27日,中国教育和科研计算机网CERNET第二十八/二十九届学术年会技术报告开讲。Coremail副总裁吴秀诚与与会专家分享了《校园邮箱与邮件数据安全的信创探索与应用》,针对校园邮箱面临的困境及钓鱼邮件进行深度剖析。

吴总介绍,校园邮箱面临着邮件账号被盗风险高、邮件安全意识亟待提升、国际交流困难重重、邮件安全系统维护难度高四大问题。

根据Coremail安全数据中心监测,教育行业位于钓鱼邮件接收量之首,而校园邮箱由于用户量大,意识单纯,往往容易被钓鱼邮件迷惑,从而造成损失,因此开展钓鱼演练势在必行!近期,教育主管部门也正式发文,明确要求高校开展钓鱼邮件专项演练,将反钓鱼工作防范于未然!

Coremail一直致力于校园邮件安全探索和防护,根据学校管理方与用户方的需求特点,提供更具有针对性的校园邮解决方案,截至目前,Coremail累计服务高校近400所,具有丰富的高校邮箱实践经验。

Coremail 反钓鱼演练囊括超链接钓鱼、恶意附件钓鱼和二维码钓鱼三大类手法,近100个基本场景模板,包括但不限于“密码修改”、“薪资调整”、“系统升级”等热门主题,可以结合客户实际情况与时事热点进行定制主题,部分场景还可根据客户的自身情况和具体要求深度定制。并对报告及时反馈分析,帮助管理员对数据的洞察和后续改进措施的实施。

吴总提出,筑牢校园信息安全防线,不仅需要钓鱼演练提升师生安全意识,守护好账号安全也是很有必要的,Coremail邮件解决方案配备二次验证和客户端专用密码,能有效应对暴力破解等恶意攻击。

在国产化方面,Coremail紧跟国家步伐,完成广泛兼容,支持信创和非信创环境,适配目前主流的信创服务器、CPU、操作系统、数据库、中间件,通过对不同软硬件环境进行编译适配,实现全栈国产化。高校可根据实际需要进行部分基础软硬件的国产化替换,Coremail邮箱系统可提供适配的信创版本。

据悉,本届年会由CERNET管理委员会指导,CERNET网络中心主办,福州大学承办,CERNET专家委员会和福建省教育厅协办。年会为期4天,主题为“IPv6下一代互联网:关键技术研发、应用融合创新”。期间还举办了中国高校CIO、网络安全、IPv6技术、无线和智慧校园、互联网超算、IPv6应用、数据治理与资源共享、网络运行管理等多个分论坛。

未来,Coremail将与高校携手,护航邮箱安全建设,共谋校园安全发展趋势,推动教育+信创的融合创新,赋能教育数字化转型高质量发展!


收起阅读 »

以为 flv.js 直播超简单,结果被延迟和卡顿整疯了

web
大家好,我是杨成功。 之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。 实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播...
继续阅读 »

大家好,我是杨成功。


之前写过一篇浏览器直播的文章,叫《用一个 flv.js 播放监控的例子,带你深撅直播流技术》。这片文章的热度还不错,主要内容就是科普直播是什么,以及如何在浏览器中播放直播。


实现方法很简单,使用一个流行的第三方包 flv.js,即可快速播放直播。


在我们的项目中也使用这种方式,比如播放海康监控器的直播、教学直播等都可以正常播放。然而在产品成熟后,我们发现直播中有两个致命问题:



  1. 直播延迟,播越久延迟越高。

  2. 直播卡顿,无法判断什么时候卡顿。


解决上述两个问题是直播稳定性和可用性的关键,下面就来详解一下。


抗延迟关键 —— “追帧”


使用 flv.js 直播,需要一个 标签承载直播画面。默认情况下 video 标签用于播放点播(录制好的)视频,因此它会一边播放一边下载。


点播不要求实时性,暂停之后再继续播放,视频会接着暂停的画面继续播放;而如果是直播,暂停后继续播放时必须切换到最新的画面帧,这就是 “追帧” 的概念。


一图胜千言,不追帧的效果是这样的:


iShot_2023-11-07_11.29.55.gif


追帧的效果是这样的:


iShot_2023-11-07_11.44.16.gif


可以看到,设置追帧后的暂停重播,会立即切换到最新的画面。


在实际场景中,直播没有暂停按钮,但是常常会因为网络问题卡顿。如果卡顿恢复后视频没有追帧,就会导致直播延迟越来越高。


使用 mpegts.js 替代 flv.js


据传说,flv.js 的作者是一个高中毕业在 B 站上班的小伙子,月薪仅仅不到 5k。后来小伙离职去了日本,无法更新 flv.js,于是有了 mpegts.js。


目前 flv.js 已停止维护,mpegts.js 是其升级版,开发者是同一个人。涉及到追帧的高级功能,mpegts.js 支持的更好。在 flv.js 主页也可以看到推荐:


image.png


mpegts.js 的用法与 flv.js 基本一致,如下:


import mpegts from 'mpegts.js';

let config = {};
let player = mpegts.createPlayer(
{
type: 'flv',
isLive: true,
url: 'http://xxxx.flv',
},
config,
);

mpegts.js 提供了自动追帧的配置项 liveBufferLatencyChasing,开启自动追帧方法如下:


let config = {
liveBufferLatencyChasing: true,
};

设置自动追帧后,虽然延迟问题解决了,但画面可能会更加卡顿。这里涉及到 IO 缓存的问题。


配置 IO 缓存,优化追帧卡顿


首先思考一个问题:直播的延迟越低越好吗?


从需求上讲,当然是越低越好;可从技术上讲,并不是越低越好。


直播是实时流,从远端拉流并实时解码播放,但这个过程极容易受到网络影响。不管是推流端或拉流端遇到了网路抖动,数据传输受阻,直播必然会卡顿,这个是正常现象。


怎么办呢?这个时候就要用到 IO 缓存,牺牲一点实时性,用延迟换取流畅。


假设播放器缓存了 1 秒的数据流,并将直播延迟 1 秒播放。当遇到网络抖动时,播放器会读取缓存数据继续播放,网络恢复后再向缓冲区追加数据,这样用户在看直播时,完全感受不到卡顿。


但如果网络异常时间超过 1 秒,缓冲区中的数据读取完毕,直播还是会卡住;如果加大缓存量,缓存了 3 秒的数据,这又会导致直播延迟过高。


所以,设置缓存可以有效解决追帧卡顿问题;若要在保证流畅的前提下,尽可能地降低延迟,则需要一个合理的缓存值。


mpegts.js 提供了 liveBufferLatencyMaxLatencyliveBufferLatencyMinRemain 两个配置项来控制缓存时间,分别表示最大缓存时间和最小缓存时间,单位为秒。


以下方配置为例,缓存时间设置越长、流畅性越好、延迟越高:


let config = {
liveBufferLatencyChasing: true, // 开启追帧
liveBufferLatencyMaxLatency: 0.9, // 最大缓存时间
liveBufferLatencyMinRemain: 0.2, // 最小缓存时间
};

实际的缓存时间会根据网络情况动态变化,值的范围在上述两个配置项之间。


处理卡顿关键 —— “断流检测”


直播是实时流播放,任何一个环节出现异常,都会导致直播卡顿、出现黑屏等现象。这是因为实时拉取的流数据断开了,我们称之为“断流”。


多数情况下的断流都是网络原因导致,此时可能需要提醒用户“当前网络拥堵”、或者显示“直播加载中”的字样,告诉用户发生了什么。


而实现这些功能的前提,必须要知道流什么时候断开,我们就需要做“断流检测”。


mpegts.js 提供了几个内置事件来监听直播的状态,常用如下:



  • mpegts.Events.ERROR:出现异常事件。

  • mpegts.Events.LOADING_COMPLETE:流结束事件。

  • mpegts.Events.STATISTICS_INFO:流状态变化事件。


前两个事件分别会在出现异常和直播结束的时候触发,监听方法如下:


let player = mpegts.createPlayer({...})

player.on(mpegts.Events.ERROR, e=> {
console.log('发生异常')
});
player.on(mpegts.Events.LOADING_COMPLETE, (e) => {
console.log("直播已结束");
});

当未发生异常、且直播未结束的情况下,我们就需要监听直播卡顿。通过监听 STATISTICS_INFO 事件来实现。


首先科普一下:播放器在播放直播时需要实时解码,每一帧画面过来,就需要解码一次。当直播卡顿时,没有画面过来,解码也会暂停,因此可以通过已解码的帧数来判断是否卡顿。


STATISTICS_INFO 事件的回调函数参数中,有一个 decodedFrames 属性,正是表示当前已解码的帧数,我们来看一下:


player.on(mpegts.Events.STATISTICS_INFO, (e) => {
console.log("解码帧:"e.decodedFrames); // 已经解码的帧数
});

在直播过程中,上述回调函数会一直执行,打印结果如下:


image-1.png


可以看到,解码帧一直在递增,表示直播正常。当直播卡顿时,打印结果是这样的:


2023-11-08-21-17-53.png


解码帧连续 9 次卡在了 904 这个值不变,这是因为直播卡顿了,没有画面需要解码。


所以,判断卡顿的方法是将上一次的解码帧与当前解码帧做对比,如果值一致则出现了卡顿。


当然轻微的卡顿不需要处理。我们可以将连续 N 次出现相同的解码帧视为一次卡顿,然后执行自己的业务逻辑。


当解码帧的值长时间没有变化时,我们可以视为推流已结束,此时可以主动结束直播。


作者:杨成功
来源:juejin.cn/post/7299037876636663847
收起阅读 »

用一个 flv.js 播放监控的例子,带你深撅直播流技术

web
大家好,我是杨成功。 本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。 究其原因,一方面 GitH...
继续阅读 »

大家好,我是杨成功。


本文记录一下在使用 flv.js 播放监控视频时踩过的各种各样的坑。虽然官网给的 Getting Started 只有短短几行代码,跑一个能播视频的 demo 很容易,但是播放时各种各样的异常会搞到你怀疑人生。


究其原因,一方面 GitHub 上文档比较晦涩,说明也比较简陋;另一方面是受“视频播放”思维的影响,没有对的足够认识以及缺乏处理流的经验。


下面我将自己踩过的坑,以及踩坑过程中补充的相关知识,详细总结一下。


大纲预览


本文介绍的内容包括以下方面:



  • 直播与点播

  • 静态数据与流数据

  • 为什么选 flv?

  • 协议与基础实现

  • 细节处理要点

  • 样式定制


点播与直播


啥是直播?啥是点播?


直播就不用说了,抖音普及之下大家都知道直播是干嘛的。点播其实就是视频播放,和咱们哔哩哔哩看视频一摸一样没区别,就是把提前做好的视频放出来,就叫点播。


点播对于我们前端来说,就是拿一个 mp4 的链接地址,放到 video 标签里面,浏览器会帮我们处理好视频解析播放等一些列事情,我们可以拖动进度条选择想看的任意一个时间。


但是直播不一样,直播有两个特点:



  1. 获取的是流数据

  2. 要求实时性


先看一下什么叫流数据。大部分没有做过音视频的前端同学,我们常接触的数据就是 ajax 从接口获取的 json 数据,特别一点的可能是文件上传。这些数据的特点是,它们都属于一次性就能拿到的数据。我们一个请求,一个响应,完整的数据就拿回来了。


但是流不一样,流数据获取是一帧一帧的,你可以理解为是一小块一小块的。像直播流的数据,它并不是一个完整的视频片段,它就是很小的二进制数据,需要你一点一点的拼接起来,才有可能输出一段视频。


再看它的实时性。如果是点播的话,我们直接将完整的视频存储在服务器上,然后返回链接,前端用 video 或播放器播就行了。但是直播的实时性,就决定了数据源不可能在服务器上,而是在某一个客户端。


数据源在客户端,那么又是怎么到达其他客户端的呢?


这个问题,请看下面这张流程图:


Untitled Diagram.drawio (7).png


如图所示,发起直播的客户端,向上连着流媒体服务器,直播产生的视频流会被实时推送到服务端,这个过程叫做推流。其他客户端同样也连接着这个流媒体服务器,不同的是它们是播放端,会实时拉取直播客户端的视频流,这个过程叫做拉流


推流—> 服务器-> 拉流,这是目前流行的也是标准的直播解决方案。看到了吧,直播的整个流程全都是流数据传输,数据处理直面二进制,要比点播复杂了几个量级。


具体到我们业务当中的摄像头实时监控预览,其实和上面的完全一致,只不过发起直播的客户端是摄像头,观看直播的客户端是浏览器而已。


静态数据与流数据


我们常接触的文本,json,图片等等,都属于静态数据,前端用 ajax 向接口请求回来的数据就是静态数据。


像上面说到的,直播产生的视频和音频,都属于流数据。流数据是一帧一帧的,它的本质是二进制数据,因为很小,数据像水流一样连绵不断的流动,因此非常适合实时传输。


静态数据,在前端代码中有对应的数据类型,比如 string,json,array 等等。那么流数据(二进制数据)的数据类型是什么?在前端如何存储?又如何操作?


首先明确一点,前端是可以存储和操作二进制的。最基本的二进制对象是 ArrayBuffer,它表示一个固定长度,如:


let buffer = new ArrayBuffer(16) // 创建一个 16 字节 的 buffer,用 0 填充
alert(buffer.byteLength) // 16

ArrayBuffer 只是用于存储二进制数据,如果要操作,则需要使用 视图对象


视图对象,不存储任何数据,作用是将 ArrayBuffer 的数据做了结构化的处理,便于我们操作这些数据,说白了它们是操作二进制数据的接口。


视图对象包括:



  • Uint8Array:每个 item 1 个字节

  • Uint16Array:每个 item 2 个字节

  • Uint32Array:每个 item 4 个字节

  • Float64Array:每个 item 8 个字节


按照上面的标准,一个 16 字节 ArrayBuffer,可转化的视图对象和其长度为:



  • Uint8Array:长度 16

  • Uint16Array:长度 8

  • Uint32Array:长度 4

  • Float64Array:长度 2


这里只是简单介绍流数据在前端如何存储,为的是避免你在浏览器看到一个长长的 ArrayBuffer 不知道它是什么,记住它一定是二进制数据。


为什么选 flv?


前面说到,直播需要实时性,延迟当然越短越好。当然决定传输速度的因素有很多,其中一个就是视频数据本身的大小。


点播场景我们最常见的 mp4 格式,对前端是兼容性最好的。但是相对来说 mp4 的体积比较大,解析会复杂一些。在直播场景下这就是 mp4 的劣势。


flv 就不一样了,它的头部文件非常小,结构简单,解析起来又块,在直播的实时性要求下非常有优势,因此它成了最常用的直播方案之一。


当然除了 flv 之外还有其他格式,对应直播协议,我们一一对比一下:



  • RTMP: 底层基于 TCP,在浏览器端依赖 Flash。

  • HTTP-FLV: 基于 HTTP 流式 IO 传输 FLV,依赖浏览器支持播放 FLV。

  • WebSocket-FLV: 基于 WebSocket 传输 FLV,依赖浏览器支持播放 FLV。

  • HLS: Http Live Streaming,苹果提出基于 HTTP 的流媒体传输协议。HTML5 可以直接打开播放。

  • RTP: 基于 UDP,延迟 1 秒,浏览器不支持。


其实早期常用的直播方案是 RTMP,兼容性也不错,但是它依赖 Flash,而目前浏览器下 Flash 默认是被禁用的状态,已经被时代淘汰的技术,因此不做考虑。


HLS 协议也很常见,对应视频格式就是 m3u8。它是由苹果推出,对手机支持非常好,但是致命缺点是延迟高(10~30 秒),因此也不做考虑。


RTP 不必说,浏览器不支持,剩下的就只有 flv 了。


但是 flv 又分为 HTTP-FLVWebSocket-FLV,它两看着像兄弟,又有什么区别呢?


前面我们说过,直播流是实时传输,连接创建后不会断,需要持续的推拉流。这种需要长连接的场景我们首先想到的方案自然是 WebSocket,因为 WebSocket 本来就是长连接实时互传的技术。


不过呢随着 js 原生能力扩展,出现了像 fetch 这样比 ajax 更强的黑科技。它不光支持对我们更友好的 Promise,并且天生可以处理流数据,性能很好,而且使用起来也足够简单,对我们开发者来说更方便,因此就有了 http 版的 flv 方案。


综上所述,最适合浏览器直播的是 flv,但是 flv 也不是万金油,它的缺点是前端 video 标签不能直接播放,需要经过处理才行。


处理方案,就是我们今天的主角:flv.js


协议与基础实现


前面我们说到,flv 同时支持 WebSocket 和 HTTP 两种传输方式,幸运的是,flv.js 也同时支持这两种协议。


选择用 http 还是 ws,其实功能和性能上差别不大,关键看后端同学给我们什么协议吧。我这边的选择是 http,前后端处理起来都比较方便。


接下来我们介绍 flv.js 的具体接入流程,官网在这里


假设现在有一个直播流地址:http://test.stream.com/fetch-media.flv,第一步我们按照官网的快速开始建一个 demo:


import flvjs from 'flv.js'
if (flvjs.isSupported()) {
var videoEl = document.getElementById('videoEl')
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: 'http://test.stream.com/fetch-media.flv'
})
flvPlayer.attachMediaElement(videoEl)
flvPlayer.load()
flvPlayer.play()
}

首先安装 flv.js,代码的第一行是检测浏览器是否支持 flv.js,其实大部分浏览器是支持的。接下来就是获取 video 标签的 DOM 元素。flv 会把处理后的 flv 流输出给 video 元素,然后在 video 上实现视频流播放。


接下来是关键之处,就是创建 flvjs.Player 对象,我们称之为播放器实例。播放器实例通过 flvjs.createPlayer 函数创建,参数是一个配置对象,常用如下:



  • type:媒体类型,flvmp4,默认 flv

  • isLive:可选,是否是直播流,默认 true

  • hasAudio:是否有音频

  • hasVideo:是否有视频

  • url:指定流地址,可以是 https(s) or ws(s)


上面的是否有音频,视频的配置,还是要看流地址是否有音视频。比如监控流只有视频流没有音频,那即便你配置 hasAudio: true 也是不可能有声音的。


播放器实例创建之后,接下来就是三步走:



  • 挂载元素:flvPlayer.attachMediaElement(videoEl)

  • 加载流:flvPlayer.load()

  • 播放流:flvPlayer.play()


基础实现流程就这么多,下面再说一下处理过程中的细节和要点。


细节处理要点


基本 demo 跑起来了,但若想上生产环境,还需要处理一些关键问题。


暂停与播放


点播中的暂停与播放很容易,播放器下面会有一个播放/暂停按键,想什么时候暂停都可以,再点播放的时候会接着上次暂停的地方继续播放。但是直播中就不一样了。


正常情况下直播应该是没有播放/暂停按钮以及进度条的。因为我们看的是实时信息,你暂停了视频,再点播放的时候是不能从暂停的地方继续播放的。为啥?因为你是实时的嘛,再点播放的时候应该是获取最新的实时流,播放最新的视频。


具体到技术细节,前端的 video 标签默认是带有进度条和暂停按钮的,flv.js 将直播流输出到 video 标签,此时如果点击暂停按钮,视频也是会停住的,这与点播逻辑一致。但是如果你再点播放,视频还是会从暂停处继续播放,这就不对了。


那么我们换个角度,重新审视一下直播的播放/暂停逻辑。


直播为什么需要暂停?拿我们视频监控来说,一个页面会放好几个摄像头的监控视频,如果每个播放器一直与服务器保持连接,持续拉流,这会造成大量的连接和消耗,流失的都是白花花的银子。


那我们是不是可以这样:进去网页的时候,找到想看的摄像头,点击播放再拉流。当你不想看的时候,点击暂停,播放器断开连接,这样是不是就会节省无用的流量消耗。


因此,直播中的播放/暂停,核心逻辑是拉流/断流


理解到这里,那我们的方案应该是隐藏 video 的暂停/播放按钮,然后自己实现播放和暂停的逻辑。


还是以上述代码为例,播放器实例(上面的 flvPlayer 变量)不用变,播放/暂停代码如下:


const onClick = isplay => {
// 参数 isplay 表示当前是否正在播放
if (isplay) {
// 在播放,断流
player.unload()
player.detachMediaElement()
} else {
// 已断流,重新拉流播放
player.attachMediaElement(videoEl.current)
player.load()
player.play()
}
}

异常处理


用 flv.js 接入直播流的过程会遇到各种问题,有的是后端数据流的问题,有的是前端处理逻辑的问题。因为流是实时获取,flv 也是实时转化输出,因此一旦发生错误,浏览器控制台会循环连续的打印异常。


如果你用 react 和 ts,满屏异常,你都无法开发下去了。再有直播流本来就可能发生许多异常,因此错误处理非常关键。


官方对异常处理的说明不太明显,我简单总结一下:


首先,flv.js 的异常分为两个级别,可以看作是 一级异常二级异常


再有,flv.js 有一个特殊之处,就是它的 事件错误 都是用枚举来表示,如下:



  • flvjs.Events:表示事件

  • flvjs.ErrorTypes:表示一级异常

  • flvjs.ErrorDetails:表示二级异常


下面介绍的异常和事件,都是基于上述枚举,你可以理解为是枚举下的一个 key 值。


一级异常有三类:



  • NETWORK_ERROR:网络错误,表示连接问题

  • MEDIA_ERROR:媒体错误,格式或解码问题

  • OTHER_ERROR:其他错误


二级级异常常用的有三类:



  • NETWORK_STATUS_CODE_INVALID:HTTP 状态码错误,说明 url 地址有误

  • NETWORK_TIMEOUT:连接超时,网络或后台问题

  • MEDIA_FORMAT_UNSUPPORTED:媒体格式不支持,一般是流数据不是 flv 的格式


了解这些之后,我们在播放器实例上监听异常:


// 监听错误事件
flvPlayer.on(flvjs.Events.ERROR, (err, errdet) => {
// 参数 err 是一级异常,errdet 是二级异常
if (err == flvjs.ErrorTypes.MEDIA_ERROR) {
console.log('媒体错误')
if(errdet == flvjs.ErrorDetails.MEDIA_FORMAT_UNSUPPORTED) {
console.log('媒体格式不支持')
}
}
if (err == flvjs.ErrorTypes.NETWORK_ERROR) {
console.log('网络错误')
if(errdet == flvjs.ErrorDetails.NETWORK_STATUS_CODE_INVALID) {
console.log('http状态码异常')
}
}
if(err == flvjs.ErrorTypes.OTHER_ERROR) {
console.log('其他异常:', errdet)
}
}

除此之外,自定义播放/暂停逻辑,还需要知道加载状态。可以通过以下方法监听视频流加载完成:


player.on(flvjs.Events.METADATA_ARRIVED, () => {
console.log('视频加载完成')
})

样式定制


为什么会有样式定制?前面我们说了,直播流的播放/暂停逻辑与点播不同,因此我们要隐藏 video 的操作栏元素,通过自定义元素来实现相关功能。


首先要隐藏播放/暂停按钮,进度条,以及音量按钮,用 css 实现即可:


/* 所有控件 */
video::-webkit-media-controls-enclosure {
display: none;
}
/* 进度条 */
video::-webkit-media-controls-timeline {
display: none;
}
video::-webkit-media-controls-current-time-display {
display: none;
}
/* 音量按钮 */
video::-webkit-media-controls-mute-button {
display: none;
}
video::-webkit-media-controls-toggle-closed-captions-button {
display: none;
}
/* 音量的控制条 */
video::-webkit-media-controls-volume-slider {
display: none;
}
/* 播放按钮 */
video::-webkit-media-controls-play-button {
display: none;
}

播放和暂停的逻辑上面讲了,样式这边自定义一个按钮即可。除此之外我们还可能需要一个全屏按钮,看一下全屏的逻辑怎么写:


const fullPage = () => {
let dom = document.querySelector('.video')
if (dom.requestFullscreen) {
dom.requestFullscreen()
} else if (dom.webkitRequestFullScreen) {
dom.webkitRequestFullScreen()
}
}

其他自定义样式,比如你要做弹幕,在 video 上面盖一层元素自行实现就可以了。


作者:杨成功
来源:juejin.cn/post/7044707642693910541
收起阅读 »

前端访问系统文件夹

web
随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。 使用方法 在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java...
继续阅读 »

随着前端技术和浏览器的升级,越来越多的功能可以在前端实现而不用依赖于后端。其中,访问系统文件夹是一个非常有用的功能,例如上传文件时,用户可以直接从自己的电脑中选择文件。


使用方法


在早期的浏览器中,要访问系统中的文件夹需要使用 ActiveX 控件或 Java Applet,这些方法已经逐渐淘汰。现在,访问系统文件夹需要使用HTML5的 API。


最常使用的 API 是FileAPI,配合 input[type="file"] 通过用户的交互行为来获取文件。但是,这种方法需要用户选择具体的文件而不是像在系统中打开文件夹来进行选择。


HTML5 还提供了更加高级的 API,如 showDirectoryPicker。它支持在浏览器中打开一个目录选择器,从而简化了选择文件夹的流程。这个 API 的使用也很简单,只需要调用 showDirectoryPicker() 方法即可。


async function pickDirectory() {
const directoryHandle = await window.showDirectoryPicker();
console.log(directoryHandle);
}

但是需要注意的是该api的兼容性较低,目前所支持的浏览器如下图所示:


image.png


点击一个按钮后,调用 pickDirectory() 方法即可打开选择文件夹的对话框,选择完文件夹后,该方法会返回一个 FileSystemFileHandle 对象,开发者可以使用这个对象来访问所选择的目录的内容。


应用场景


访问系统文件夹可以用于很多场景,下面列举几个常用的场景。


上传文件


在前端上传文件时,用户需要选择所需要上传的文件。这时,打开一个文件夹选择器,在用户选择了一个文件夹后,就可以读取文件夹中的文件并进行上传操作。


本地文件管理


将文件夹中的文件读取到前端后,可以在前端进行一些操作,如修改文件名、查看文件信息等。这个在 纯前端文件管理器 中就被广泛使用。


编辑器功能


访问系统文件夹可以将前端编辑器与本地的文件夹绑定,使得用户直接在本地进行编写代码,而不是将代码保存到云端,这对于某些敏感数据的处理尤为重要。


结尾的话


通过HTML5的 API,前端可以访问到系统中的文件夹,这项功能可以应用于上传文件、本地文件管理和编辑器功能等场景,为用户带来了极大的便利。


作者:白椰子
来源:juejin.cn/post/7222636308740014135
收起阅读 »

【手把手教学】基于vue封装一个安全键盘组件

web
基于vue封装一个安全键盘组件 为什么需要安全键盘 大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下...
继续阅读 »

基于vue封装一个安全键盘组件



为什么需要安全键盘


大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下来。


系统键盘缓存最方便拿到的就是利用系统输入法自动更正的字符串输入记录。 缓存文件的地址是:



/private/var/mobile/Library/Keyboard/dynamic-text.dat



导出该缓存文件,查看内容,欣喜的发现一切输入记录都是明文存储的。因为系统不会把所有的用户输入记录都当作密码等敏感信息来处理。 一般情况下,一个常规 iPhone 用户的 dynamic-text.dat 文件,高频率出现的字符串就是用户名和密码。


使用自己定制的安全键盘的原因主要有:



  • 避免第三方读取系统键盘缓存

  • 防止屏幕录制 (自己定制的键盘按键不加按下效果)


实现方案


封装组件


首先建一个文件safeKeyboard.vue安全键盘子组件.



话不多说,直接上才艺(代码)



<template>
<div class="keyboard">
<div class="key_title">
<p><img src="../../../../static/img/ic_logo@2x.png"><span>小猴子的安全键盘span>p>
div>
<p v-for="keys in keyList" :style="(keys.length<10&&keys.indexOf('ABC')<1&&keys.indexOf('del')<1&&keys.indexOf('suc')<1)?'padding: 0px 20px;':''">
<template v-for="key in keys">
<i v-if="key === 'top'" @click.stop="clickKey" @touchend.stop="clickKey" class="tab-top"><img class="top" :src='top_img'>i>
<i v-else-if="key === 'del'" @click.stop="clickKey" @touchend.stop="clickKey" class="key-delete"><img class="delete" src='删除图标路径'>i>
<i v-else-if="key === 'blank'" @click.stop="clickBlank" class="tab-blank">空格i>
<i v-else-if="key === 'suc'" @click.stop="success" @touchend.stop="success" class="tab-suc">确定i>
<i v-else-if="key === '.?123' || key === 'ABC'" @click.stop="symbol" class="tab-sym">{{(status==0||status==1)?'.?123':'ABC'}}i>
<i v-else-if="key === '123' || key === '#+='" @click.stop="number" class="tab-num">{{status==3?'123':'#+='}}i>
<i v-else @click.stop="clickKey" @touchend.stop="clickKey">{{key}}i>
template>
p>
div>
template>

<script>
export default {
data () {
return {
keyList: [],
status: 0, // 0 小写 1 大写 2 数字 3 符号
topStatus: 0, // 0 小写 1 大写
top_img: require('小写图片路径'),
lowercase: [
['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
['top', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'del'],
['.?123', 'blank', 'suc']
],
numbercase: [
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
['-', '/', ':', ';', '(', ')', '$', '&', '@', '"'],
['#+=', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
symbolcase: [
['[', ']', '{', '}', '#', '%', '^', '*', '+', '='],
['_', '\\', '|', '~', '<', '>', '€', '`', '¥', '·'],
['123', '.', ',', '?', '!', "'", 'del'],
['ABC', 'blank', 'suc']
],
uppercase: [
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'],
['top', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', 'del'],
['.?123', 'blank', 'suc']
],
equip: !!navigator.userAgent.toLocaleLowerCase().match(/ipad|mobile/i)// 是否是移动设备
}
},
props: {
option: {
type: Object
}
},

mounted () {
this.keyList = this.lowercase
},

methods: {
tabHandle ({value = ''}) {
if (value.indexOf('tab-num') > -1) {
if (this.status === 3) {
this.status = 2
this.keyList = this.numbercase
} else {
this.status = 3
this.keyList = this.symbolcase
}
// 数字键盘数据
} else if (value.indexOf('delete') > -1) {
this.emitValue('delete')
} else if (value.indexOf('tab-blank') > -1) {
this.emitValue(' ')
} else if (value.indexOf('tab-point') > -1) {
this.emitValue('.')
} else if (value.indexOf('tab-sym') > -1) {
if (this.status === 0) {
this.topStatus = 0
this.status = 2
this.keyList = this.numbercase
} else if (this.status === 1) {
this.topStatus = 1
this.status = 2
this.keyList = this.numbercase
} else {
if (this.topStatus == 0) {
this.status = 0
this.top_img = require('小写图片路径')
this.keyList = this.lowercase
}else{
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
}
}
// 符号键盘数据
} else if (value.indexOf('top') > -1) {
if (this.status === 0) {
this.status = 1
this.keyList = this.uppercase
this.top_img = require('大写图片路径')
} else {
this.status = 0
this.keyList = this.lowercase
this.top_img = require('小写图片路径')
}
} else if (value.indexOf('tab-suc') > -1) {
this.$emit('closeHandle', this.option) // 关闭键盘
}
},
number (event) {
this.tabHandle(event.srcElement.classList)
},
clickBlank (event) {
this.tabHandle(event.srcElement.classList)
},
symbol (event) {
this.tabHandle(event.srcElement.classList)
},
success (event) {
this.tabHandle(event.srcElement.classList)
},
english (event) {
this.tabHandle(event.srcElement.classList)
},
clickKey (event) {
if (event.type === 'click' && this.equip) return
let value = event.srcElement.innerText
value ? this.emitValue(value) : this.tabHandle(event.srcElement.classList)
},

emitValue (key) {
this.$emit('keyVal', key) // 向父组件传值
},

closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},
}
}
script>
<style scoped lang="scss">
.keyboard {
width: 100%;
margin: 0 auto;
font-size: 18px;
border-radius: 2px;
background-color: #fff;
box-shadow: 0 -2px 2px 0 rgba(89,108,132,0.20);
user-select: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 999;
pointer-events: auto;
.key_title{
height: 84px;
font-size: 32px;
color: #0B0B0B;
overflow: hidden;
margin-bottom: 16px;
p{
display: flex;
justify-content: center;
align-items: center;
min-width: 302px;
height: 32px;
margin: 32px auto 0px;
img{
width: 32px;
height: 32px;
margin-right: 10px;
}
}
}
p {
width: 99%;
margin: 0 auto;
height: 84px;
margin-bottom: 24px;
display: flex;
display: -webkit-box;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
box-sizing: border-box;
i {
position: relative;
display: block;
margin: 0px 5px;
height: 84px;
line-height: 84px;
font-style: normal;
font-size: 48px;
border-radius: 8px;
width: 64px;
background-color: #F2F4F5;
box-shadow: 0 2px 0 0 rgba(0,0,0,0.25);
text-align: center;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
-webkit-box-flex: 1;
img{
width: 48px;
height: 48px;
}
}
i:first-child{
margin-left: 0px
}
i:last-child{
margin-right: 0px
}
i:active {
background-color: #A9A9A9;
}
.tab-top, .key-delete, .tab-num, .tab-eng, .tab-sym{
background-color: #CED6E0;
}
.tab-top,.key-delete {
display: flex;
justify-content: center;
align-items: center;
width: 84px;
height: 84px;
}
.tab-top{
margin-right: 30px;
font-size: 32px;
}
.key-delete{
margin-left: 30px;
}
.tab-num, .tab-eng, .tab-sym{
font-size: 32px;
}
.tab-point {
width: 70px;
}
.tab-blank, .tab-suc{
text-align: center;
line-height: 84px;
font-size: 32px;
color: #000;
}
.tab-blank{
flex: 2.5;
}
.tab-suc{
background-color: #CFA46A;
}
}
p:last-child{
margin-bottom: 8px;
}
}
style>

但是,键盘的特性是,点击除键盘和输入框以外的地方,键盘收起。


所以还需要一个clickoutside.js文件,用来自定义一个指令,实现需求:


代码如下:


export default {
bind(el, binding, vnode) {
function documentHandler(e) {
if (el.contains(e.target)) {
return false;
}
if (binding.expression) {
binding.value(e);
}
}
el.__vueClickOutside__ = documentHandler;
document.addEventListener('click', documentHandler);
},
unbind(el, binding) {
document.removeEventListener('click', el.__vueClickOutside__);
delete el.__vueClickOutside__;
}
};

然后在safeKeyboard.vue中引入:


import clickoutside from './clickoutside'

并注册局部指令:


directives: { clickoutside }

然后绑定方法:


class="keyboard" v-clickoutside="closeModal">

声明方法:


closeModal (e) {
if (e.target !== this.option.sourceDom) {
this.$emit('closeHandle', this.option)
this.keyList = this.lowercase
}
},

安全键盘组件就构建完成了,接下来是在需要用到安全键盘的页面引入使用了。


使用组件


引入组件

import Keyboard from './safeKeyboard'

components: {
Keyboard
}

使用范例

type="password" ref="setPwd" v-model='password'/> 

v-if="option.show" :option="option" @keyVal="getInputValue" @closeHandle="onLeave">

键盘相关数据对象及方法


  • option


option: {
show: false, // 键盘是否显示
sourceDom: '', // 键盘绑定的Input元素
_type: '' // 键盘绑定的input元素ref
},


  • getInputValue



getInputValue(val)会接收键盘录入的数据,val是输入的单个字符或者是删除操作,由于是单个字符,所以需在方法中手动拼接成字符串。在方法中根据option._type区分是哪个输入框的数据。




  • onLeave



onLeave()相当于blur,这是由于在移动端H5项目中,input获取焦点时会调起手机软键盘,所以需要禁止软键盘被调起来,办法是:



document.activeElement.blur() // ios隐藏键盘

this.$refs.setPwd.blur() // android隐藏键盘


就相当于强制使input元素处于blur状态,那么软键盘就不会被调起,所以如果要做blur监听,就需要onLeave()。



但是这样出现了一个新的问题,输入框里面没有光标!!虽然不影响业务逻辑,但是用户用起来会很不舒服。


所以,只能和input元素说再见了,自己手写一个吧:


输入框组件


再来一个子组件cursorBlink.vue


<template>
<div class="cursor-blink" @click.stop="isShow">
<span v-if="pwd.length>0" :style="options.show?'':'border:0;animation:none;'" class="blink">{{passwordShow}}span>
<span v-else style="color: #ddd" :style="options.show?'':'border:0;animation:none;'" class="blink_left">{{options.desc}}span>
div>
template>
<script>
export default {
props: {
pwd: {
type: String
},
options: {
type: Object
},
},
data(){
return {
passwordShow: '',
}
},
mounted() {
if(this.pwd.length > 0){
for (let i = 0; i < this.pwd.length; i++) {
this.passwordShow += '*' // 显示为掩码
}
}
},
watch: {
pwd(curVal, oldVal){
if (oldVal.length < curVal.length) {
// 输入密码时
this.passwordShow += '*'
} else if (oldVal.length > curVal.length) {
// 删除密码时
this.passwordShow = this.passwordShow.slice(0, this.passwordShow.length - 1)
}
}
},
methods: {
isShow(){
this.$emit('cursor')
}
},
}
script>
<style lang="scss" scoped>
.cursor-blink{
display: inline-block;
width: 500px;
height: 43px;
letter-spacing: 0px;
word-spacing: 0px;
padding: 2px 0px;
font-size: 28px;
overflow: hidden;
.blink,.blink_left{
display: inline;
margin: 0px;
}
.blink{ // 输入密码后
border-right: 2px solid #000;
animation: blink 1s infinite steps(1, start);
}
.blink_left{ // 输入密码前
border-left: 2px solid #000;
animation: blinkLeft 1s infinite steps(1, start);
}
}
@keyframes blink {
0%, 100% {
border-right: 2px solid #fff;
}
50% {
border-right: 2px solid #000;
}
}
@keyframes blinkLeft {
0%, 100% {
border-left: 2px solid #fff;
}
50% {
border-left: 2px solid #000;
}
}
style>

引入之后光荣的接替input的位置:


<CursorBlink :pwd='password' ref="setPwd" :options='option2' @cursor="onFocus"></CursorBlink>

数据方法说明:


option2: {
show: false, // 区分输入前输入后
desc: '请重复输入密码' // 相当于placeholder
},

onFocus() 相当于input标签的focus

这样一个完美的安全键盘就做好了。


我是摸鱼君,你的【三连】就是摸鱼君创作的最大动力,如果本篇文章有任何错误和建议,欢迎大家留言!


作者:摸鱼君111
来源:juejin.cn/post/7309158055018168346
收起阅读 »

前人在 vue 项目中的 “砍树型“ 写法,让后人乘不了凉!

web
前言 最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。 本篇文章就列举一些,在 vue 项目中...
继续阅读 »

前言


最近在协助小伙伴解决问题时,在项目中都会遇到一些 “砍树型” 的写法,这些写法容易让后续 简单 的需求变得 复杂,都说 "前人栽树后人乘凉",但项目中有些写法是真的让后人乘不了凉的,甚至还得被迫加入 “砍树队伍”。


本篇文章就列举一些,在 vue 项目中的 “砍树型” 的写法,以及分析一下如何写才更合适 “栽树”,如果你有更好的方案,欢迎在评论区分享!!!


89DFA925.png


砍树 & 栽树


由于项目源码不便于直接展示,下面会使用同等的代码实例来替代。


其项目技术栈为:vue2 + vue-class-component + vue-property-decorator + typescript


滥用 watch


砍树型写法


@Watch('person', { deep: true })
doSomething(){}

@Watch('person.name', { deep: true })
doSomething(){}

@Watch('person.age', { deep: true })
doSomething(){}

@Watch('person.hobbies', { deep: true })
doSomething(){}

第一次看到这个写法我有点迷茫,但想了想好像也不难理解:



  • 首先 person.x 的部分监听 是为了处理针对不同属性值发生修改时要执行的特定逻辑

  • 而针对 person 的整体监听 是为了执行属于公共部分的逻辑


因此,上面的写法就只是相当于只是少写了几个 if 的条件分支罢了,更何况还都用了深度监听,而实际上这种 简化方式vue 内部会实例化出多个 Watcher 实例,如下:


image.png


image.png


image.png


栽树型写法


针对上述写法,如果说后续需要追加不同属性变更时的新逻辑,会有两种情况:



  • 看懂的人,会使用一样的 person.x 的部分监听 方式去添加新逻辑

    • 实际上一个 Watcher 就可以解决,没必要实例化多个 Watcher



  • 看不懂的人,可能会把新逻辑杂糅在 person 的整体监听 的公共逻辑中

    • 还得注意添加执行时机条件的判断,很容易出问题




总之,这两种情况都并不好,因此更推荐原本 if 的写法:


@Watch('person', { deep: true })
doSomething(newVal, oldVal){
doSomethingCommon() // 公共逻辑

if(newVal.name !== oldVal.name){
doSomethingName() // 逻辑抽离
}

if(newVal.age !== oldVal.age){
doSomethingAge() // 逻辑抽离
}

...
}

值得注意的是,当使用 watch 深度监听对象时,其中的 newValoldVal 的值会一致,因为此时它们指向的是 同一个对象,因此如果真的需要如上例的方式来使用,就需要提前将目标对象进行 深度克隆


因此,这两种写法到底哪种是 "栽树",哪种是 "砍树",需要见仁见智了!


946CB97F.gif


不合理使用 async/await


砍树型写法


记得当时有反馈前端视图更新太慢,因为后端通过日志查看接口响应速度还是很快的,于是查看前端代码时发现类似如下的使用:


 async mounted(){
await this.request1(); // 耗时接口
this.request2(); // request2 需要依赖 request1 的请求结果
this.request3(); // request3 不需要依赖任何的请求结果
this.request4(); // request4 不需要依赖任何请求结果
}

这种写法就导致了 request3request4 虽然不需要依赖前面异步请求结果,但是必须要等待耗时操作完成才能请求,而视图更新又必须等待接口调用完成。


上述写法可能在 开发 和 测试 环境没有太明显的影响,但是在 生产环境,这个影响就会被放大,因为不同环境数据量不同,所接口响应速度更不同,并且用户可能不会注意你的数据是否准备完成就进行相应操作,这个时候就有可能出现问题。


93DE32BE.gif


栽树型写法


为了更快的得到视图更新,针对以上写法可进行如下调整:



  • 将无关相互依赖的请求前置在 await 之前

    • 这种方式适合使用的场景就是 request1 本身还需要再其他地方单独调用,因此其内部不适合在存放额外的逻辑


     async mounted(){
    this.request3();
    this.request4();

    await this.request1(); // 耗时接口
    this.request2(); // request2 需要依赖 request1 的请求结果
    }


  • 将相互依赖的请求在统一在内部处理

    • 例如,将 request2 放置到 request1 的具体实现中,这种方式适用于 request1request2 间在任何情况下都有紧密联系的情况下,当然也可以在 request1 内通过 条件判断 决定是否要执行 request2


     async mounted(){
    this.request3();
    this.request4();
    this.request1(); // 耗时接口
    }

    async request1(){
    const res = await asyncReq();
    this.request2(res); // request2 需要依赖 request1 的请求结果
    }



同时还需要注意的是,虽然 request2 需要依赖 request1 的结果,但是对于视图更新来说,却没有必要等待 request2 请求完成后再去更新视图,也就是说,request1 请求结束后有需要更新视图的部分就可以先更新,这样视图更新时机就不会延后。


组件层层传参


砍树型写法


项目中有一个模版切换的功能,而这个模版功能封装成了一个组件,在外部看起来是 Grandpa 组件,实际上其内部包含了 Parents 组件,而最底层使用的是 Son 组件


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents :data="data" @customEvent="customEvent" />

// 底层组件
<Son :data="data" @customEvent="customEvent" />

由于底层的 Son 组件 需要使用到 props data自定义事件 customEvent,在代码中通过逐层传递的方式来实现,甚至在 Grandpa 组件Parents 组件 中都有对 props.datadeepClone 深克隆 且修改后在往下层传递。


缺点很明显了:



  • 重复定义 props

    • 需要分别在 Grandpa、Parents、Son 三个组件中定义相关的 propsevent



  • props 的修改来源不确定

    • 由于 Grandpa、Parents 组件都对 props.data 有修改,在出现问题需要排查时可能都要排查 Grandpa、Parents 组件




栽树型写法


上面的写法属实繁琐且不优雅,实际上可以通过 $attrs$listeners 来实现 属性和事件透传,如下:


// 顶层组件
<Grandpa :data="data" @customEvent="customEvent" />

// 中间层组件
<Parents v-bind="$attrs" v-on="$listeners" />

// 底层组件
<Son v-bind="$attrs" v-on="$listeners" />

而其中涉及到直接通过 deepClone 深克隆 的原因应该是为了便于 直接 增加/删除 props.data 中的属性,实际上应该在 props 提供层 提供修改的方法。


946B61BF.gif


没有必要的响应式数据


砍树型写法


很多时候在 Vue 中我们需要在

收起阅读 »

Android 视频图像实时文字化

一、前言 在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。 下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我...
继续阅读 »

一、前言


在本篇之前,很多博客已经实现过图片文本化,但是由于渲染方式的不合理,大部分设计都做不到尽可能实时播放,本篇将在已有的基础上进行一些优化,使得视频文字化具备一定的实时性。


下图总体上视频和文字化的画面基本是实时的,上面是SurfaceView,下面是我们自定义的View类,通过实时抓帧然后实时转bitmap,做到了基本同步。



二、现状


目前很多流行的方式是修改像素的色值,这个性能差距太大,导致卡顿非常严重,无法做到实时性。当然也有通过open gl mask实现的,但是在贴图这一块我们知道,open gl只支持绘制三角、点和线,因此“文字”纹理生成还得利用Canvas实现。



但对于对帧率要求不高的需求,是不是有更好的方案呢?


三、优化方案


优化点1: 使用Shader


网上很多博客都是利用Bitmap#getPixel和Bitmap#setPixel进行,这个计算量显然太大了,就算使用open gl 也未必好,因此首先解决的问题就是使用Shader着色。


优化点2: 提前计算好单个文字所占的最大空间


显然这个原因是更加整齐的排列文字,其次也可以做到降低计算量和提高灵活度


优化点3:使用队列


对于了编解码的开发而言,使用队列不仅可以复用buffer,而且还能提高绘制性能,另外必要时可以丢帧。


基于以上三点,基本可以做到实时字符化画面,当然,我们这里是彩色的,对于灰度图的需求,可通过设置Paint的ColorMatrix实现,总之,要避免遍历修改像素了RGB。


四、关键代码


使用shader着色


 this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);
//用下面方式清空bitmap
boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

计算字符size


    private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

定义双队列,实现控制和享元机制


    private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
}

static class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}

完整代码


public class WordBitmapView extends View {
private final DisplayMetrics mDM;
private TextPaint mCharPaint;
private TextPaint mDrawerPaint = null;
private Bitmap inputBitmap;
private Rect charMxWidth = null ;
private String text = "a1b2c3d4e5f6h7j8k9l0";
private float textBaseline;
private BitmapShader bitmapShader;
public WordBitmapView(Context context) {
this(context, null);
}
public WordBitmapView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WordBitmapView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDM = getResources().getDisplayMetrics();
initPaint();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);

textBaseline = getTextPaintBaseline(mDrawerPaint);
}


public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recyclePool.clear();
bitmapPool.clear();
}

Matrix matrix = new Matrix();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
if (width <= 1 || height <= 1) {
return;
}
BitmapItem bitmapItem = bitmapPool.linkedBlockingQueue.poll();
if (bitmapItem == null || inputBitmap == null) {
return;
}
if(!bitmapItem.isUsed){
return;
}
canvas.drawBitmap(bitmapItem.bitmap,matrix,mDrawerPaint);
bitmapItem.isUsed = false;
try {
recyclePool.linkedBlockingQueue.offer(bitmapItem,16,TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static float getTextPaintBaseline(Paint p) {
Paint.FontMetrics fontMetrics = p.getFontMetrics();
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

private Rect computeMaxCharWidth(TextPaint drawerPaint, String text) {
if (TextUtils.isEmpty(text)) {
return null;
}
Rect result = new Rect(); // 文字所在区域的矩形
Rect md = new Rect();
for (int i = 0; i < text.length(); i++) {
String s = text.charAt(i) + "";
if(TextUtils.isEmpty(s)) {
continue;
}
drawerPaint.getTextBounds(s, 0, 1, md);
if (md.width() > result.width()) {
result.right = md.width();
}
if (md.height() > result.height()) {
result.bottom = md.height();
}
}
return result;
}

private BitmapPool bitmapPool = new BitmapPool();
private BitmapPool recyclePool = new BitmapPool();

static class BitmapPool {
int width;
int height;
private LinkedBlockingQueue<BitmapItem> linkedBlockingQueue = new LinkedBlockingQueue<>(5);
public void clear(){
Iterator<BitmapItem> iterator = linkedBlockingQueue.iterator();
do{
if(!iterator.hasNext()) break;
BitmapItem next = iterator.next();
if(!next.bitmap.isRecycled()) {
next.bitmap.recycle();
}
iterator.remove();
}while (true);
}

public int getWidth() {
return width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

public void setWidth(int width) {
this.width = width;
}
}

class BitmapItem{
Bitmap bitmap;
boolean isUsed = false;
}


//视频图片入队
public void queueInputBitmap(Bitmap inputBitmap) {
this.inputBitmap = inputBitmap;

if(charMxWidth == null){
charMxWidth = computeMaxCharWidth(mDrawerPaint,text);
}
if(charMxWidth == null || charMxWidth.width() == 0){
return;
}

if(this.bitmapPool != null && this.inputBitmap != null){
if(this.bitmapPool.getWidth() != this.inputBitmap.getWidth()){
bitmapPool.clear();
recyclePool.clear();
}else if(this.bitmapPool.getHeight() != this.inputBitmap.getHeight()){
bitmapPool.clear();
recyclePool.clear();
}
}
bitmapPool.setWidth(inputBitmap.getWidth());
bitmapPool.setHeight(inputBitmap.getHeight());
recyclePool.setWidth(inputBitmap.getWidth());
recyclePool.setHeight(inputBitmap.getHeight());

BitmapItem boardBitmap = recyclePool.linkedBlockingQueue.poll();
if (boardBitmap == null && inputBitmap != null) {
boardBitmap = new BitmapItem();
boardBitmap.bitmap = Bitmap.createBitmap(inputBitmap.getWidth(), inputBitmap.getHeight(), Bitmap.Config.ARGB_8888);
}
boardBitmap.isUsed = true;
int bitmapWidth = inputBitmap.getWidth();
int bitmapHeight = inputBitmap.getHeight();
int unitWidth = (int) (charMxWidth.width() *1.5);
int unitHeight = charMxWidth.height() + 2;
int centerY = charMxWidth.centerY();
float hLineCharNum = bitmapWidth * 1F / unitWidth;
float vLineCharNum = bitmapHeight * 1F / unitHeight;


this.bitmapShader = new BitmapShader(inputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
this.mCharPaint.setShader(bitmapShader);

boardBitmap.bitmap.eraseColor(Color.TRANSPARENT);

Canvas drawCanvas = new Canvas(boardBitmap.bitmap);
int k = (int) (Math.random() * text.length());
for (int i = 0; i < vLineCharNum; i++) {
for (int j = 0; j < hLineCharNum; j++) {
int length = text.length();
int x = unitWidth * j;
int y = centerY + i * unitHeight;
String c = text.charAt(k % length) + "";
drawCanvas.drawText(c, x, y + textBaseline, mCharPaint);
k++;
}
}
try {
bitmapPool.linkedBlockingQueue.offer(boardBitmap,16, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
postInvalidate();

}
private void initPaint() {
// 实例化画笔并打开抗锯齿
mCharPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mCharPaint.setAntiAlias(true);
mCharPaint.setStyle(Paint.Style.FILL);
mCharPaint.setStrokeCap(Paint.Cap.ROUND);
mDrawerPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mDrawerPaint.setAntiAlias(true);
mDrawerPaint.setStyle(Paint.Style.FILL);
mDrawerPaint.setStrokeCap(Paint.Cap.ROUND);
}


}

五、总结


Android中Shader是非常重要的工具,我们无需单独修改像素的情况下就能实现快速渲染字符,得意与Shader出色的渲染能力。另外由于时间原因,这里对字符的绘制并没有做到很精确,仅仅选了一些比较中规中列的排列,后续再继续完善吧。


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

搞不懂,我的手机没有公网IP,服务器响应报文如何被路由到手机?

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“ 我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经...
继续阅读 »

6年前,在我刚毕业的时候,我困惑于一个问题:“我的手机和个人电脑等设备只有内网IP,而没有公网IP的情况下,公网服务器的响应IP报文如何被路由到我的内网设备呢?“


我知道:任何联网的设备都可以主动连接公网IP的服务器,因为公网IP是全球唯一分配的,IP层报文经过路由器的层层路由可以到达公网服务器。然而我实在想不通:公网服务器的响应报文该如何回来?


这个问题让我感到很困扰,我上学时学的计算机网络知识已经还给老师了,我尝试询问周围的同事,可惜没有人能给出一个可靠的答案。直到我了解到NAT(网络地址转换)技术,才最终解答了我的疑问。



NAT,即网络地址转换,是一种用于解决IP地址短缺问题的技术。它通过在内部网络和公共网络之间建立一个转换表,将多个内部私有IP地址映射为一个公共IP地址,实现多个设备共享一个公网IP地址的功能。



在科普 NAT 之前,有必要说明一下 内网IP。


0. 内网 IP 不能随意分配!


内网IP的分配是有规范的,不可以随意分配。如果内网IP和公网IP冲突,网络报文无法被路由器正确地路由。因为路由器不知道这个IP报文应该被路由到内网设备,还是路由到上一层网关(直至公网IP)。


为了避免冲突,IP协议中事先规定了三个网段作为内网IP的范围,分别是



  1. A类地址的 10.0.0.0 至 10.255.255.255

  2. B类地址的 172.16.0.0 至 172.31.255.255,

  3. C类地址的 192.168.0.0 至 192.168.255.255。


因此在一个局域网内,以上三个网段的设备都是内网设备,除此外基本(排除 127.0.0.1 等)都是公网设备。这样所有的IP地址都不会冲突!


在家用局域网中,通常使用的是 192.168.xxx.xxx 的格式;而在公司的机房中,由于设备数量庞大,一般会选择以 10.xxx.xxx.xxx 开头的网段。这是因为 192 开头的C类地址能够满足家用局域网设备数量的需求,而 10.xxx.xxx.xxx 网段可以适应大规模的公司机房环境,其中可能存在数以百万计的物理服务器,和数以千万计的虚拟机或者Docker实例。


1. 公网流量如何路由到内网设备


设想一下,内网IP为 192.168.100.100 的用户设备,请求到公网服务器。如果公网服务器收到的IP报文中,显示来源是 192.168.100.100。因为 192 开头的 IP 是内网地址,所以服务器响应用户请求时,就会错误地把请求路由到内网,无法正确路由到用户设备上!


所以…… 正确的显示来源是什么呢?


内网设备想要 “连通” 公网服务器,是需要路由器等网络设备层层转发的,正如下图而言,内网设备需要有公网IP的网络出口才能连通 到另一个公网设备。


image.png
因此,刚才问题答案是,内网IP报文到达公网机器时,来源应该被设置为 相应的 运营商公网出口IP。即公网出口网络设备要把IP来源改为自己。这样公网服务器收到请求报文后,对应的响应IP报文也会回复到用户端的公网出口IP。


看下图,用户端的IP报文到达运营商网络出口时,IP来源被替换为 运营商公网出口IP。下图中网络来源为 192 开头的内网Ip 地址,被替换为公网出口 IP(100.100.100.100)。像这种偷偷更换来源 IP 和目标 IP 的行为在 NAT 技术上很常见,后面会经常看到!


image.png


2. 公网机器发送响应报文时


当公网服务器响应时,IP 目标地址 是运营商公网出口而非用户的内网地址!然后运营商服务器会再次转发,转发前,运营商机器需要知道该转发给谁,转发给哪个用户设备。


如下图所示,用户端(192.168.100.100) 访问 公网地址(200.200.200.200)的IP 来源和目标被替换的过程。


image.png
首先公网机器响应给 运营商公网出口时。


第一步:来源 IP 是(200.200.200.200),目标IP 是(100.100.100.100)。


然后,运营商将 IP 报文转发给用户设备时,来源 IP 还是公网机器的 IP(200.200.200.200)不变化。然而目标 IP 修改为用户设备的 IP(192.168.xx.xx)。


对于用户设备而言,发送请求时目标IP 是公网机器,收到响应时来源 IP 还是公网机器。用户设备丝毫没有感觉到,在中间被路由转发的过程,目标和来源 IP 频繁被路由设备修改!


在这个环节,有一个关键问题:运营商收到公网机器的响应时,它怎么知道该路由给哪个用户设备!


3. NAT 如何进行地址映射


最简单的映射方式是:每一个内网 IP 都映射到一个运营商的公网IP。即内网 Ip 和运营商公网 IP 一对一映射!


这种方式很少见,用户设备和内网设备非常多,这样非常耗费公网IP,一般不会采用。


TCP 和 UDP 协议除基于 IP 地址外,还有端口,如果引入端口参与映射,则大大提高运营商公网 IP 的利用度!


一个 TCP 报文 包括如下参数:



  1. 用户 IP + 用户端口

  2. 公网 IP + 公网端口


这四个参数非常关键,相当于是 TCP 连接的唯一主键。当运营商收到公网机器的响应报文时,它可以拿到四个参数分别为:


运营商公网 IP + 端口 、目标机器公网 IP + 端口。其中关键的参数有三位:运营商公网端口,目标机器公网 IP 和端口。



为什么运营商公网 IP 不关键呢?因为根据 IP 路由协议,响应报文已经被路由到该机器,每一个运营商公网出口都会维护一套单独的 映射表 ,所以自己的 IP 地址不关键。



当前的难点是:如何根据 运营商公网端口,目标机器公网 IP 和 端口 三个参数,映射到 用户 IP 和端口的问题。


用户请求时,会建立映射表。当收到用户请求时,运营商服务器的 NAT 模块会分配一个端口供本次请求使用,建立一个映射项:运营商机器端口 + 目标公网 IP + 目标公网端口 这三个参数映射到 用户 IP 和用户端口


例如下表


NAT 映射表运营商机器端口目标公网 IP目标公网端口用户 IP用户端口
1300200.200.200.20080192.168.22.226000
2
3

在运营商机器转发IP报文时,除替换IP外,也会替换端口。相比NAT 一比一映射IP地址,增加端口映射,可以大大提高运营商公网 IP 的利用度。接下来有个问题?


每个机器的端口最大数为 65535,说明每个运营商机器最多 同时支持转发 65535 个请求?


这个推论不成立。


从上面的映射表可以看到,运营商机器收到响应报文时,会根据 三个关键参数 进行映射,而非只根据 自身端口映射。以上面 NAT 映射表的第一条记录为例,运营商机器的 300 端口,并非仅仅服务于 200.200.200.200 这次请求。300 端口还可以同时服务 250.250.250.250 + 80 端口,以及其他连接!


由于映射的参数有三个,而不仅仅是运营商端口一项,因此并发程度非常高。


理论的最大并发度应该是 65535 * (公网 IP 数)* 65535,这个并发度非常高。 一个运营商机器似乎支持海量的NAT连接,实际上,并非海量。


因为常用的 Http 协议端口是 80,目标公网机器的端口数常用的基本是 80 端口。


其次用户常用的软件非常集中,例如微信、抖音、稀土掘金等,访问的公网 IP 也集中于这些公司的 IP 地址。


所以基于此,最高并发度变为:65535 * 常用的公网 IP数 * 有限的端口数(80、443)。


这个并发度并非海量,但是基本上足够使用了,一个小区或办公区的网络设备数量不会过于庞大。65535 * 有限的公网 IP数 * 有限的端口数,这样的并发度足够支持一般场景使用。


除非出现极端的情况! 即一个小区的大量用户集中访问于一个公网 IP 的 80 端口,这样网络流量一定会发生拥塞!在某些用户流量集中的区域,可以安排更多的 NAT 设备,提供更多的公网 IP。


一个小区一个公网 IP 吗?


根据 chatgpt 的回答,通常情况下,一个小区只有一个公网 IP。
image.png


上大学时,偶然了解到 SQL注入,我感觉很新奇。后来对一个兼职网站进行 SQL 注入的尝试。经过几次尝试后,我发现无法再访问这个网站。宿舍和其他几个宿舍的同学也无法访问此网站。我不禁得意洋洋,难道是因为我的攻击导致了这个网站的崩溃?


后来我找到其他大学的高中同学,让他们访问这个网站,他们访问是没问题的。那时我明白了,这个网站只是封掉了我们学校的公网 IP,或者是这栋男生宿舍楼的公网 IP ,它并没有崩溃!


个人经验来看,一个小区或者办公楼会根据实际的需要安排一定数量的公网 IP,一般情况下共用一个公网 IP。


总结


当 公网 IP 不够用时,可通过 NAT 协议实现多个用户设备共享同一个公网 IP,提高 公网IP 地址的利用度。Ipv4 的地址数量有限,在 2011 年已经被分配完,未来如果全面实现了 ipv6 协议,我们手机等终端设备也可能会有一个公网 IP。


但是公网 Ip 全球都可以访问,与此对应的网络安全问题不可忽视。NAT 技术则可以有效保护用户设备,让用户安全上网,这也是它附带的好处。


如果看完有所收获,感谢点赞支持!


作者:五阳神功
来源:juejin.cn/post/7307892574722637835
收起阅读 »

🌅 让你的用户头像更具艺术感,实现一个自动生成唯一渐变色的头像组件

web
前言 这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置...
继续阅读 »

前言


这一篇文章依然是组件实现系列,这次我们来实现一个基于用户昵称动态生成用户头像的组件。在很多中后台场景中,用户并不需要自己去上传头像,而是简单的展示一个基本的头像图片,但是这样就太没意思啦。这次头像组件的,预期是能够基于用户昵称字符串生成某种颜色,然后设置为 渐变色背景 并添加一些额外的细节就能展示出一个相对好看的头像。


实现过程


Avatar 组件


首先我们封装一个 Avatar 组件,这里我引用了 ChakraUI 组件库:


const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return (
<Box w="12" h="12" p="0" {...rest}>
<ChakraAvatar {...getGradientStyle(name)} name={name} />
Box>

);
};

export default Avatar;

样式生成函数


这一步还是很简单的,在组件的 props 中我使用了一个 getGradientStyle(name) 函数用于获取头像组件的样式,下面我们来实现这个函数:


const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
fontFamily: "Helvetica, Arial, sans-serif",
};
};

随机颜色函数


这个函数返回一个包含渐变和其他属性的样式对象,这里面还有一个核心的函数 getRandomColor,这个函数可以基于字符串生成颜色,并且可以自己传入透明度,下面说说这个函数是如何实现的:


function getRandomColor(str: string, alpha: number) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}
const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

解析一下 getRandomColor 函数的执行过程:



  1. 首先遍历字符串 str 中的每个字符,计算它们的 ASCII 码值之和 asciiSum

  2. 使用这个数字作为参数,通过 Math.sin 函数生成一个介于 -1 和 1 之间的正弦值。再通过 Math.sin

  3. 将这个正弦值乘以 256 并四舍五入,得到一个介于 0 和 255 之间的整数,作为 rgba 颜色值的红色、绿色、蓝色分量。

  4. 最后,将传入的透明度 alpha 与颜色值一起组成一个 rgba 颜色值字符串并返回。


实现的效果如下图:


image.png


字体阴影


基本的一个头像已经做好了,但是我们还需要补充一些细节。为了让字体在浅色背景下也可以看清楚,我们可以为字体加上一点字体阴影。


textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)"

添加前后的对比可以看下图,重点是像 "王" 字这种比较浅色的是不是一下就清晰多了。



添加前:
image.png


添加后:
image.png



背景纹理


现在的头像背景效果已经蛮不错了,但是只是一个渐变背景我还是觉得太单调了,如果里面能够增加一点纹理就好了。于是我又添加了一个水波纹的效果,实现的代码如下:


return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};

在原有背景的基础上,我增加了 before 伪元素,将它的位置大小与背景重叠,然后通过 backgroundImage 设置了背景的纹理:


backgroundImage: \`repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )

再通过将原本的背景色设置为 transparent 透明,将 before 的层级设置为 -1,将背后的纹理给透出去,实现的效果如下图:


image.png


好不好看这点仁者见仁,但是我觉得是精致了一点的。before 中的纹理是这样的:


image.png


是不是有点像阿尔卑斯糖呢 🍭。这个效果我是从一个背景生成网站中调整并生成的,网站地址在这:http://www.magicpattern.design/tools/css-b… ,这个网站提供了很多好看的背景纹理,可以在线调整颜色和间距,预览效果,还能直接复制 CSS 到代码里使用。


image.png


最后再放一下 26 个字母生成的头像效果,不同字母生成的颜色差别还是相对比较大的。我觉着效果都还不错,即便是不太好看的颜色在背景纹理和渐变的加成下也还凑合能看:


image.png


性能优化


最后我们看回前面生成颜色的函数,在代码里有这么一段用于生成两个渐变色的逻辑:


const color1 = getRandomColor(text, 1);
const color2 = getRandomColor(text, 0.7);

但是这里我们仅仅是改变了透明度,颜色其实是不变的,那么去计算两次颜色就没有必要了,我们可以先获取颜色,然后再改透明度,避免重复计算颜色。修改的方式有很多种,如果是你,你会怎么改呢?我的调整方式是这样的:


function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

这里我将函数进行柯里化,将一个多参数的函数转换为一系列单参数的函数,每个函数接受一个参数并返回一个函数,最终返回值由最后一个函数计算得出。


我们可以通过对 getRandomColor() 函数进行一次调用来获取一个特定字符串对应的颜色生成函数,然后多次调用该生成函数并传入不同的透明度参数来生成不同的颜色。这是柯里化的一个常见应用场景。


最终完整代码如下:


import { Avatar as ChakraAvatar, AvatarProps } from "@chakra-ui/react";

function getRandomColor(str: string) {
let asciiSum = 0;
for (let i = 0; i < str.length; i++) {
asciiSum += str.charCodeAt(i);
}

const red = Math.abs(Math.sin(asciiSum) * 256).toFixed(0);
const green = Math.abs(Math.sin(asciiSum + 1) * 256).toFixed(0);
const blue = Math.abs(Math.sin(asciiSum + 2) * 256).toFixed(0);
return (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

const getGradientStyle: (text: string) => AvatarProps = (text) => {
const color = getRandomColor(text);
const color1 = color(1);
const color2 = color(0.7);

return {
backgroundImage: `linear-gradient(135deg, ${color1}, ${color2})`,
color: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "10px",
background: "transparent",
textShadow: "1px 1px 3px rgba(0, 0, 0, 0.2)",
fontFamily: "Helvetica, Arial, sans-serif",
position: "relative",
_before: {
content: `""`,
position: "absolute",
left: "0",
top: "0",
w: "full",
h: "full",
borderRadius: "10px",
backgroundColor: "white",
backgroundImage: `repeating-radial-gradient( circle at 0 0, transparent 0, #ffffff 9px ), repeating-linear-gradient( ${color2}, ${color1} )`,
zIndex: "-1",
},
};
};

const Avatar: React.FC<{ name: string } & AvatarProps> = ({
name,
...rest
}
) =>
{
return <ChakraAvatar {...getGradientStyle(name)} {...rest} name={name} />;
};

export default Avatar;


总结


后面我想着可以再将 纹理的类型和方向 也基于传入的字符串去定制,这样就能实现随机度更高的定制头像了,如果未来有了更好的效果我再单独写篇文章分享!


后续这类组件封装的文章可能会出一个系列,也准备把这些组件都开源了,如果有使用或打算使用 ChakraUI 进行项目搭建的同学欢迎插眼关注。如果文章对你有帮助除了收藏之余可以点个赞 👍,respect



作者:oil欧哟
来源:juejin.cn/post/7218506966545170493
收起阅读 »

Android 使用Xfermode合成TabBarView

一、前言 PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXf...
继续阅读 »

一、前言


PorterDuffXfermode  作为Android重要的合成组件,可以通过区域叠加方式进行裁剪和合成,起作用和Path.Op 类似,对于音视频开发中使用蒙版抠图和裁剪的需求,这种情况一般生成不了Path时,因此只能使用PorterDuffXfermode进行合成,当然Paint设置Shader也具备一定的能力,但是还是无法做到很多效果。


二、案例



这个案例使用了Bitmap合成,在边缘区域对色彩裁剪,从而实现了圆觉裁剪。


模版



//裁剪区域



技术上没有太多难点,但要注意的是Xfermode是2个Bitmap之间只使用,不像Shader那样可以单独使用。


Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

另外一点就是速度计算,利用了没有时间的逼近减速公式,当然你可以使用动画去实现


 float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;

下面是速度控制逻辑


    @Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

全部逻辑


public class TabBarView extends View implements Runnable {
//画笔
private Paint mSolidPaint;
//中间竖线与边框间隙
private int gapPadding = 0;
//平分量
private int mDivideNumber = 1;
//边框大小
private final float mBorderSize = 1.5f;
//避免重复绘制Bitmap,短暂保存底色bitmap
private Bitmap srcRoundBitmap;
//图片混合模式
private PorterDuffXfermode mPorterDuffXfermode;
private PointF point;
//内容区域大小
private float contentWidth;
private float contentHeight;
//滑动到的目标区域
private int mTargetZone;
//滑动速度
private float mSpeed;
//主调颜色
private int primaryColor;
//默认字体颜色
private int textColor;
//焦点字体颜色
private int selectedTextColor;
//item
private CharSequence[] mStringItems;
//字体大小
private float textSize;
//是否处于滑动
private boolean isSliding;

Bitmap dstBitmap;
Bitmap resultBitmap;

private RectF rectBound = new RectF();

public TabBarView(Context context) {
super(context);
init(null, 0);
}

public TabBarView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}

public TabBarView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}

private void init(AttributeSet attrs, int defStyle) {
// Load attributes
final TypedArray a = getContext().obtainStyledAttributes(
attrs, R.styleable.TabBarView, defStyle, 0);

//参数值越大,速度越大,速度指数越小
mSpeed = Math.max(10 - Math.max(a.getInt(R.styleable.TabBarView_speed, 6), 6), 1);

mStringItems = a.getTextArray(R.styleable.TabBarView_tabEntries);
primaryColor = a.getColor(R.styleable.TabBarView_primaryColor, 0xFF4081);
textColor = a.getColor(R.styleable.TabBarView_textColor, primaryColor);
selectedTextColor = a.getColor(R.styleable.TabBarView_selectedTextColor, 0xffffff);
textSize = a.getDimensionPixelSize(R.styleable.TabBarView_textSize, 30);

if (mStringItems != null && mStringItems.length > 0) {
mDivideNumber = mStringItems.length;
}

a.recycle();

mSolidPaint = new Paint();
mSolidPaint.setFlags(Paint.ANTI_ALIAS_FLAG);
mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
point = new PointF(0, 0);
mTargetZone = 1;

invalidateTextPaintAndMeasurements();

}

private void invalidateTextPaintAndMeasurements() {
mSolidPaint.setColor(primaryColor);
mSolidPaint.setStrokeWidth(mBorderSize);
mSolidPaint.setTextSize(textSize);
mSolidPaint.setStyle(Paint.Style.STROKE);
mSolidPaint.setXfermode(null);
}



@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
recycleBitmap();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int paddingRight = getPaddingRight();
int paddingBottom = getPaddingBottom();

contentWidth = getWidth() - paddingLeft - paddingRight;
contentHeight = getHeight() - paddingTop - paddingBottom;
float minContentSize = Math.min(contentWidth, contentHeight);

rectBound.set(paddingLeft, paddingTop, paddingLeft + contentWidth, paddingTop + contentHeight);
canvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
for (int i = 1; i < mDivideNumber; i++) {
canvas.drawLine(paddingLeft + 1F * contentWidth * i / mDivideNumber, paddingTop + gapPadding, paddingLeft + contentWidth * i / mDivideNumber, paddingTop + contentHeight - gapPadding, mSolidPaint);

}

if (srcRoundBitmap == null) {
srcRoundBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas srcCanvas = new Canvas(srcRoundBitmap);
mSolidPaint.setStyle(Paint.Style.FILL_AND_STROKE);
srcCanvas.drawRoundRect(rectBound, minContentSize / 2F, minContentSize / 2F, mSolidPaint);
}

if(dstBitmap == null) {
dstBitmap = Bitmap.createBitmap((int) (contentWidth / mDivideNumber), (int) contentHeight, Bitmap.Config.ARGB_8888);
}
dstBitmap.eraseColor(Color.TRANSPARENT);
Canvas dstCanvas = new Canvas(dstBitmap);
dstCanvas.drawColor(Color.YELLOW);

if(resultBitmap == null) {
resultBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
}
resultBitmap.eraseColor(Color.TRANSPARENT);
Canvas resultCanvas = new Canvas(resultBitmap);
resultCanvas.drawBitmap(dstBitmap, paddingLeft + point.x, paddingTop, mSolidPaint);
mSolidPaint.setXfermode(mPorterDuffXfermode);
resultCanvas.drawBitmap(srcRoundBitmap, 0, 0, mSolidPaint);
canvas.drawBitmap(resultBitmap, 0, 0, null);

invalidateTextPaintAndMeasurements();

if (mStringItems != null) {

for (int i = 0; i < mStringItems.length; i++) {
String itemChar = mStringItems[i].toString();
float textX = (contentWidth / mDivideNumber) * i / 2 + paddingLeft + (contentWidth * (i + 1) / mDivideNumber - mSolidPaint.measureText(itemChar)) / 2;
float textY = paddingTop + (contentHeight - mSolidPaint.getFontMetrics().bottom - mSolidPaint.getFontMetrics().ascent) / 2;
int color = mSolidPaint.getColor();
mSolidPaint.setStyle(Paint.Style.FILL);
if ((i + 1) == mTargetZone && !isSliding) {
mSolidPaint.setColor(selectedTextColor);
} else {
mSolidPaint.setColor(textColor);
}
canvas.drawText(itemChar, textX, textY, mSolidPaint);
mSolidPaint.setColor(color);
mSolidPaint.setStyle(Paint.Style.STROKE);
}
}
}


@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (checkLocationIsOk(event) && !isSliding) {
return true;
}
break;
case MotionEvent.ACTION_MOVE:
return checkLocationIsOk(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
if (checkLocationIsOk(event) && !isSliding) {
float x = event.getX() - getPaddingLeft();
mTargetZone = (int) (x / (contentWidth / mDivideNumber)) + 1;
//规避区域超出范围
mTargetZone = Math.min(mTargetZone, mDivideNumber);
postToMove();
}
break;
}
return super.onTouchEvent(event);
}

private void postToMove() {
if (point.x == (mTargetZone - 1) * (contentWidth / mDivideNumber)) {
return;
}
postDelayed(this, 20);
}

/**
* 检测位置是否可用
*
* @param event
* @return
*/
private boolean checkLocationIsOk(MotionEvent event) {
float x = event.getX();
float y = event.getY();
if (x - getPaddingLeft() > 0 && (getPaddingLeft() + contentWidth - x) > 0 && y - getPaddingTop() > 0 && (getPaddingTop() + contentHeight - y) > 0) {
return true;
}
return false;
}

private void recycleBitmap(Bitmap bmp) {
if (bmp != null && !bmp.isRecycled()) {
bmp.recycle();
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getHandler().removeCallbacksAndMessages(null);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

if (widthMode != MeasureSpec.EXACTLY) {
widthSize = getResources().getDisplayMetrics().widthPixels / 2;
}

int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}

@Override
public void run() {
//计算速度,先按照最大速度5变化,如果小于5,则表示该减速停靠
float speed = Math.abs((mTargetZone - 1) * (contentWidth / mDivideNumber) - point.x) / mSpeed;
speed = (float) Math.max(1f, speed);
float vPos = (mTargetZone - 1) * (contentWidth / mDivideNumber);
if (point.x < vPos) {
point.x += speed;
if (point.x > vPos) {
point.x = vPos;
}
} else {
point.x -= speed;
if (point.x < vPos) {
point.x = vPos;
}
}
if (point.x == vPos) {
isSliding = false;
} else {
isSliding = true;
postDelayed(this, 20);
}
postInvalidate();
}

public void setSelectedTab(int tabIndex) {
mTargetZone = Math.max(Math.min(mDivideNumber, tabIndex + 1), 1);
recycleBitmap();
postToMove();
}

public void setTabItems(CharSequence[] mStringItems) {
this.mStringItems = mStringItems;
recycleBitmap();
invalidate();
}

private void recycleBitmap() {
if(dstBitmap != null && !dstBitmap.isRecycled()){
dstBitmap.recycle();
}
if(resultBitmap != null && !resultBitmap.isRecycled()){
resultBitmap.recycle();
}
resultBitmap = null;
dstBitmap = null;
}
}

我们需要自定义一些属性


<declare-styleable name="TabBarView">

<attr name="speed" format="integer" />
<attr name="tabEntries" format="reference"/>
<attr name="primaryColor" format="color|reference"/>
<attr name="textSize" format="dimension"/>
<attr name="textColor" format="color|reference"/>
<attr name="selectedTextColor" format="color|reference"/>

</declare-styleable>

还有部分需要引用的 string-array


<string-array name="tabEntries_array">
<item>A</item>
<item>B</item>
<item>C</item>
<item>D</item>
</string-array>

然后是布局文件(片段)


<com.android.jym.widgets.TabBarView
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:padding="10dp"
app:speed="4"
app:tabEntries="@array/tabEntries_array"
app:primaryColor="@color/colorAccent"
app:textColor="@color/colorPrimaryDark"
app:selectedTextColor="@android:color/white"
/>

三、总结


使用Xfermode + 蒙版进行抠图,是Android中重要的工具,本篇作为技术储备,后续会通过这种方式实现一些新的功能。


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

为什么最近听说 Go 岗位很少很难?

大家好,我是煎鱼。 其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。 今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子? 盘一下数据 ...
继续阅读 »

大家好,我是煎鱼。


其实这个话题已经躺在我的 TODO 里很久了,近来很多社区的小伙伴都私下来交流,也有在朋友圈看吐槽 Go 上海的大会没什么人。还不如 Rust 大会,比较尴尬。


今天主要是从个人角度看看为什么 Go 岗位看起来近来很难的样子?


盘一下数据


从以往的大的数据分析来看,Go 岗位最多的是分布以下几个城市:



TOP3 是北京、上海、深圳。


再从常用的招聘软件来看,目前互联网行业用的应该是 XX 直聘。我们从其提供的招聘岗位数量来看。


北京:



上海:



深圳:



从这 5 个月的时间区间来看,北京和上海是在螺旋式下跌;深圳探底创新低。(不过我认为从招聘季节来看,基本都是螺旋式的)


从数值来看,只有北京的 Go 岗位有所上涨。上海、深圳都在持续减少。但整体都是在下滑趋势的。


另外一个角度来看,招聘岗位这么多,也有个好几千。是不是没什么问题,风生水起?


还是要看看活跃度的。我快速的看了下深圳,Go 岗位。第一页 30 条招聘岗位,只有 8 个是本周活跃的。刚刚活跃和 3 日内活跃的,加起来就 2~3 个。


综合数据来看,招聘岗位的数量在向下走,招聘者登陆平台的活跃度不高。


看看小行情


可能很容易就得出了 Go 完全不行的结论。我们也得看看别的编程语言岗位的。


Java,深圳:



PHP,深圳:



综合来看,其实并不是某一门语言的岗位不太行,或者要凉了。是由于整体的行情原因,招聘岗位和招聘者的活跃度都在大量的收缩。


像是以往,Go 最多的招聘岗位也是由各中大型公司撑起的。例如:字节跳动、腾讯、滴滴、百度等。他们收缩了,增量也就下来了。


而一般这种放水阶段,很多是面向 GY 企的,会有一些项目出现。例如:前段时间很火热的信创。


但有做 2B 的同学应该了解,这块有些企业会加码,要求使用 Java 语言。这一块的增量,与 Java 相比较,Go 是比较难在正面承接到的。


总结


其实不单单 Go 岗位少了。由于宏观的影响,我们常接触到的招聘岗位都少了。普遍来讲,还是建议如果没想明白就先苟着,降低负债、现金为王。


人是环境的反应器,常常会受到各种因素的影响。但在这种时期,可能想清楚自己的目标和感兴趣的内容、方向,会是一个不错的提高机会。


而在缩量的环境下,如果想找到增量。就要去一个风口或上行周期的领域。例如最近比较火的 AI。之前的新能源,不过也要警惕是个新坑。



文章持续更新,可以微信搜【脑子进煎鱼了】阅读,本文 GitHub github.com/eddycjy/blo… 已收录,学习 Go 语言可以看 Go 学习地图和路线,欢迎 Star 催更。



推荐阅读



作者:煎鱼eddycjy
来源:juejin.cn/post/7308697586532778024
收起阅读 »

封装一个工作日历组件,顺便复习一下Date常用方法

web
背景 上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。 下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。 效果展示 demo体验地址:dbfu.github.io/work-calend… 开始之前 lu...
继续阅读 »

背景


上周接到一个需求,开发一个工作日历组件。找了一圈没找到合适的,索性自己写一个。


下面给大家分享一下组件中使用到的一些日期API和后面实现的框选元素功能。


效果展示


Kapture 2023-12-05 at 13.02.36.gif


demo体验地址:dbfu.github.io/work-calend…


开始之前


lunar-typescript


介绍组件之前先给大家介绍一个库lunar-typescript


lunar是一个支持阳历、阴历、佛历和道历的日历工具库,它开源免费,有多种开发语言的版本,不依赖第三方,支持阳历、阴历、佛历、道历、儒略日的相互转换,还支持星座、干支、生肖、节气、节日、彭祖百忌、每日宜忌、吉神宜趋、凶煞宜忌、吉神方位、冲煞、纳音、星宿、八字、五行、十神、建除十二值星、青龙名堂等十二神、黄道日及吉凶等。仅供参考,切勿迷信。


这个库封装了很多常用的api,并且使用起来也比较简单。


本文用到了上面库的获取农历和节气方法。


复习Date Api


new Date


可以使用new Date()传年月日三个参数来构造日期,这里注意一下月是从零开始的。


image.png


获取星期几


可以使用getDay方法获取,注意一下,获取的值是从0开始的,0表示星期日。


image.png


获取上个月最后一天


基于上面api,如果第三个参数传0,就表示上个月最后一天,-1,是上个月倒数第二天,以此类推。(PS:这个方法还是我有次面试,面试官告诉我的。)


image.png


获取某个月有多少天


想获取某个月有多少天,只需要获取当月最后天的日期,而当月最后一天,可以用上面new Date第三个参数传零的方式获取。


假设我想获取2023年12月有多少天,按照下面方式就可以获取到。


image.png


日期加减


假设我现在想实现在某个日期上加一天,可以像下面这样实现。


image.png


这样实现有个不好的地方,改变了原来的date,如果不想改变date,可以这样做。


image.png


比较两个日期


在写这个例子的时候,我发现一个很神奇的事情,先看例子。


image.png


大于等于结果是true,小于等于结果也是true,正常来说肯定是等于的,但是等于返回的是false,是不是很神奇。


其实原理很简单,用等于号去比较的时候,会直接比较两个对象的引用,因为是分别new的,所以两个引用肯定不相等,返回false。


用大于等于去比较的时候,会默认使用date的valueOf方法返回值去比较,而valueOf返回值也就是时间戳,他们时间戳是一样的,所以返回true。


说到这里,给大家分享一个经典面试题。


console.log(a == 1 && a == 2 && a == 3),希望打印出true


原理和上面类似,感兴趣的可以挑战一下。


这里推荐大家比较两个日期使用getTime方法获取时间戳,然后再去比较。


image.png


实战


数据结构


开发之前先把数据结构定一下,一个正确的数据结构会让程序开发变得简单。


根据上面效果图,可以把数据结构定义成这样:



/**
* 日期信息
*/

export interface DateInfo {
/**
* 年
*/

year: number;
/**
* 月
*/

month: number;
/**
* 日
*/

day: number;
/**
* 日期
*/

date: Date;
/**
* 农历日
*/

cnDay: string;
/**
* 农历月
*/

cnMonth: string;
/**
* 农历年
*/

cnYear: string;
/**
* 节气
*/

jieQi: string;
/**
* 是否当前月
*/

isCurMonth?: boolean;
/**
* 星期几
*/

week: number;
/**
* 节日名称
*/

festivalName: string;
}

/**
* 月份的所有周
*/

export interface MonthWeek {
/**
* 月
*/

month: number;
/**
* 按周分组的日期,7天一组
*/

weeks: DateInfo[][];
}

通过算法生成数据结构


现在数据结构定义好了,下面该通过算法生成上面数据结构了。


封装获取日期信息方法


/**
* 获取给定日期的信息。
* @param date - 要获取信息的日期。
* @param isCurMonth - 可选参数,指示日期是否在当前月份。
* @returns 包含有关日期的各种信息的对象。
*/

export const getDateInfo = (date: Date, isCurMonth?: boolean): DateInfo => {
// 从给定日期创建 农历 对象
const lunar = Lunar.fromDate(date);

// 获取 Lunar 对象中的农历日、月和年
const cnDay = lunar.getDayInChinese();
const cnMonth = lunar.getMonthInChinese();
const cnYear = lunar.getYearInChinese();

// 获取农历节日
const festivals = lunar.getFestivals();

// 获取 Lunar 对象中的节气
const jieQi = lunar.getJieQi();

// 从日期对象中获取年、月和日
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();

// 创建包含日期信息的对象
return {
year,
month,
day,
date,
cnDay,
cnMonth,
cnYear,
jieQi,
isCurMonth,
week: date.getDay(),
festivalName: festivals?.[0] || festivalMap[`${month + 1}-${day}`],
};
};

上面使用了lunar-typescript库,获取了一些农历信息,节气和农历节日。方法第二个参数isCurMonth是用来标记是否是当月的,因为很多月的第一周或最后一周都会补一些其他月日期。


把月日期按照每周7天格式化


思路是先获取给定月的第一天是星期几,如果前面有空白,用上个月日期填充,然后遍历当月日期,把当月日期填充到数组中,如果后面有空白,用下个月日期填充。


/**
* 返回给定年份和月份的周数组。
* 每个周是一个天数数组。
*
* @param year - 年份。
* @param month - 月份 (0-11)。
* @param weekStartDay - 一周的起始日 (0-6) (0: 星期天, 6: 星期六)。
* @returns 给定月份的周数组。
*/

const getMonthWeeks = (year: number, month: number, weekStartDay: number) => {
// 获取给定月份的第一天
const start = new Date(year, month, 1);

// 这里为了支持周一或周日在第一天的情况,封装了获取星期几的方法
const day = getDay(start, weekStartDay);

const days = [];

// 获取给定月份的前面的空白天数,假如某个月第一天是星期3,并且周日开始,那么这个月前面的空白天数就是3
// 如果是周一开始,那么这个月前面的空白天数就是2
// 用上个月日期替换空白天数
for (let i = 0; i < day; i += 1) {
days.push(getDateInfo(new Date(year, month, -day + i + 1)));
}

// 获取给定月份的天数
const monthDay = new Date(year, month + 1, 0).getDate();

// 把当月日期放入数组
for (let i = 1; i <= monthDay; i += 1) {
days.push(getDateInfo(new Date(year, month, i), true));
}

// 获取给定月份的最后一天
const endDate = new Date(year, month + 1, 0);
// 获取最后一天是星期几
const endDay = getDay(endDate, weekStartDay);

// 和前面一样,如果有空白位置就用下个月日期补充上
for (let i = endDay; i <= 5; i += 1) {
days.push(getDateInfo(new Date(year, month + 1, i - endDay + 1)));
}

// 按周排列
const weeks: DateInfo[][] = [];
for (let i = 0; i < days.length; i += 1) {
if (i % 7 === 0) {
weeks.push(days.slice(i, i + 7));
}
}

// 默认每个月都有6个周,如果没有的话就用下个月日期补充。
while (weeks.length < 6) {
const endDate = weeks[weeks.length - 1][6];
weeks.push(
Array.from({length: 7}).map((_, i) => {
const newDate = new Date(endDate.date);
newDate.setDate(newDate.getDate() + i + 1)
return getDateInfo(newDate);
})
);
}
return weeks;
};

getDay方法实现


function getDay(date: Date, weekStartDay: number) {
// 获取给定日期是星期几
const day = date.getDay();
// 根据给定的周开始日,计算出星期几在第一天的偏移量
if (weekStartDay === 1) {
if (day === 0) {
return 6;
} else {
return day - 1;
}
}
return day;
}

获取一年的月周数据


/**
* 获取年份的所有周,按月排列
* @param year 年
* @param weekStartDay 周开始日 0为周日 1为周一
* @returns
*/

export const getYearWeeks = (year: number, weekStartDay = 0): MonthWeek[] => {
const weeks = [];
for (let i = 0; i <= 11; i += 1) {
weeks.push({month: i, weeks: getMonthWeeks(year, i, weekStartDay)});
}
return weeks;
};

页面


页面布局使用了grid和table,使用grid布局让一行显示4个,并且会自动换行。日期显示使用了table布局。


如果想学习grid布局,推荐这篇文章


工作日历日期分为三种类型,工作日、休息日、节假日。在渲染单元格根据不同的日期类型,渲染不同背景颜色用于区分。


image.png


image.png


image.png


维护日期类型


背景


虽然节假日信息可以从网上公共api获取到,但是我们的业务希望可以自己调整日期类型,这个简单给单元格加一个点击事件,点击后弹出一个框去维护当前日期类型,但是业务希望能支持框选多个日期,然后一起调整,这个就稍微麻烦一点,下面给大家分享一下我的做法。


实现思路


实现框选框


定义一个fixed布局的div,设置背景色和边框颜色,背景色稍微有点透明。监听全局点击事件,记录初始位置,然后监听鼠标移动事件,拿当前位置减去初始位置就是宽度和高度了,初始位置就是div的left和top。


获取框选框内符合条件的dom元素


当框选框位置改变的时候,获取所有符合条件的dom元素,然后通过坐标位置判断dom元素是否和框选框相交,如果相交,说明被框选了,把当前dom返回出去。


判断两个矩形是否相交


interface Rect {
x: number;
y: number;
width: number;
height: number;
}

export function isRectangleIntersect(rect1: Rect, rect2: Rect) {
// 获取矩形1的左上角和右下角坐标
const x1 = rect1.x;
const y1 = rect1.y;
const x2 = rect1.x + rect1.width;
const y2 = rect1.y + rect1.height;

// 获取矩形2的左上角和右下角坐标
const x3 = rect2.x;
const y3 = rect2.y;
const x4 = rect2.x + rect2.width;
const y4 = rect2.y + rect2.height;

// 如果 `rect1` 的左上角在 `rect2` 的右下方(即 `x1 < x4` 和 `y1 < y4`),并且 `rect1` 的右下角在 `rect2` 的左上方(即 `x2 > x3` 和 `y2 > y3`),那么这意味着两个矩形相交,函数返回 `true`。
// 否则,函数返回 `false`,表示两个矩形不相交。
if (x1 < x4 && x2 > x3 && y1 < y4 && y2 > y3) {
return true;
} else {
return false;
}
}

具体实现


框选框组件实现


import { useEffect, useRef, useState } from 'react';

import { createPortal } from 'react-dom';
import { isRectangleIntersect } from './utils';

interface Props {
selectors: string;
sourceClassName: string;
onSelectChange?: (selectDoms: Element[]) => void;
onSelectEnd?: () => void;
style?: React.CSSProperties,
}

function BoxSelect({
selectors,
sourceClassName,
onSelectChange,
style,
onSelectEnd,
}: Props
) {

const [position, setPosition] = useState({ top: 0, left: 0, width: 0, height: 0 });

const isPress = useRef(false);

const startPos = useRef<any>();

useEffect(() => {
// 滚动的时候,框选框位置不变,但是元素位置会变,所以需要重新计算
function scroll() {
if (!isPress.current) return;
setPosition(prev => ({ ...prev }));
}

// 鼠标按下,开始框选
function sourceMouseDown(e: any) {
isPress.current = true;
startPos.current = { top: e.clientY, left: e.clientX };
setPosition({ top: e.clientY, left: e.clientX, width: 1, height: 1 })
// 解决误选择文本情况
window.getSelection()?.removeAllRanges();
}
// 鼠标移动,移动框选
function mousemove(e: MouseEvent) {
if (!isPress.current) return;

let left = startPos.current.left;
let top = startPos.current.top;
const width = Math.abs(e.clientX - startPos.current.left);
const height = Math.abs(e.clientY - startPos.current.top);

// 当后面位置小于前面位置的时候,需要把框的坐标设置为后面的位置
if (e.clientX < startPos.current.left) {
left = e.clientX;
}

if (e.clientY < startPos.current.top) {
top = e.clientY;
}

setPosition({ top, left, width, height })
}

// 鼠标抬起
function mouseup() {

if(!isPress.current) return;

startPos.current = null;
isPress.current = false;
// 为了重新渲染一下
setPosition(prev => ({ ...prev }));

onSelectEnd && onSelectEnd();
}

const sourceDom = document.querySelector(`.${sourceClassName}`);

if (sourceDom) {
sourceDom.addEventListener('mousedown', sourceMouseDown);
}

document.addEventListener('scroll', scroll);
document.addEventListener('mousemove', mousemove);
document.addEventListener('mouseup', mouseup);

return () => {
document.removeEventListener('scroll', scroll);
document.removeEventListener('mousemove', mousemove);
document.removeEventListener('mouseup', mouseup);

if (sourceDom) {
sourceDom.removeEventListener('mousedown', sourceMouseDown);
}
}
}, [])

useEffect(() => {
const selectDoms: Element[] = [];
const boxes = document.querySelectorAll(selectors);
(boxes || []).forEach((box) => {
// 判断是否在框选区域
if (isRectangleIntersect({
x: position.left,
y: position.top,
width: position.width,
height: position.height,
},
box.getBoundingClientRect()
)) {
selectDoms.push(box);
}
});
onSelectChange && onSelectChange(selectDoms);
}, [position]);


return createPortal((
isPress.current && (
<div
className='fixed bg-[rgba(0,0,0,0.2)]'
style={{
border: '1px solid #666',
...style,
...position,
}}
/>
)
), document.body)
}


export default BoxSelect;

使用框选框组件,并在框选结束后,给框选日期设置类型


import { Modal, Radio } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import BoxSelect from './box-select';
import WorkCalendar from './work-calendar';

import './App.css';

function App() {

const [selectDates, setSelectDates] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [dateType, setDateType] = useState<number | null>();
const [dates, setDates] = useState<any>({});

const selectDatesRef = useRef<string[]>([]);

const workDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 1)
}, [dates])

const restDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 2)
}, [dates]);

const holidayDays = useMemo(() => {
return Object.keys(dates).filter(date => dates[date] === 3)
}, [dates]);

useEffect(() => {
selectDatesRef.current = selectDates;
}, [selectDates]);

return (
<div>
<WorkCalendar
defaultWeekStartDay={0}
workDays={workDays}
holidayDays={holidayDays}
restDays={restDays}
selectDates={selectDates}
year={new Date().getFullYear()}
/>

<BoxSelect
// 可框选区域
sourceClassName='work-calendar'
// 可框选元素的dom选择器
selectors='td.date[data-date]'
// 框选元素改变时的回调可以拿到框选中元素
onSelectChange={(selectDoms) =>
{
// 内部给td元素上设置了data-date属性,这样就可以从dom元素上拿到日期
setSelectDates(selectDoms.map(dom => dom.getAttribute('data-date') as string))
}}
// 框选结束事件
onSelectEnd={() => {
// 如果有框选就弹出设置弹框
if (selectDatesRef.current.length) {
setOpen(true)
}
}}
/>
<Modal
title="设置日期类型"
open={open}
onCancel={() =>
{
setOpen(false);
setSelectDates([]);
setDateType(null);
}}
onOk={() => {
setOpen(false);
selectDatesRef.current.forEach(date => {
setDates((prev: any) => ({
...prev,
[date]: dateType,
}))
})
setSelectDates([]);
setDateType(null);
}}
>
<Radio.Gr0up
options={[
{ label: '工作日', value: 1 },
{ label: '休息日', value: 2 },
{ label: '节假日', value: 3 },
]}
value={dateType}
onChange={e =>
setDateType(e.target.value)}
/>
</Modal>
</div>

)
}

export default App


工作日历改造


给td的class里加了个date,并且给元素上加了个data-date属性


image.png


image.png


如果被框选,改变一下背景色


image.png


效果展示


Kapture 2023-12-05 at 13.02.36.gif


小结


本来想给mousemove加节流函数,防止触发太频繁影响性能,后面发现不加节流很流畅,加了节流后因为延迟,反而不流畅了,后面如果有性能问题,再优化吧。


最后


借助这次封装又复习了一下Date的一些常用方法,也学到了一些关于Date不常见但是很有用的方法。


demo体验地址:dbfu.github.io/work-calend…


demo仓库地址:github.com/dbfu/work-c…


作者:前端小付
来源:juejin.cn/post/7308948738659155983
收起阅读 »

前端程序猿复工啦~

我是一名产假复工的前端工程师打工仔,新身份新气象,今天是一个新的开始,所以想借着这一股劲做点什么,这是我的第一篇文章,主要内容是:谈谈妈妈角色和前端身份的转变,许下愿望、立下flag、展望未来。 一、过去 二人世界,三口之家,一大家子 2022年我和我的先生结...
继续阅读 »

我是一名产假复工的前端工程师打工仔,新身份新气象,今天是一个新的开始,所以想借着这一股劲做点什么,这是我的第一篇文章,主要内容是:谈谈妈妈角色和前端身份的转变,许下愿望、立下flag、展望未来。


一、过去


二人世界,三口之家,一大家子


2022年我和我的先生结婚,为了庆祝新婚,上天送给了我们一个小宝宝。2023年宝宝出生,全家都很开心。


为了帮我们分担家务和带娃,爸妈和我们住在了一起。大家有不同的生活习惯,产假期间我的护崽心理挺严重,家里发生了不少矛盾。好在,终于熬过来了,我上班了,主打一个眼不见心不烦,上班时认真工作,回家后专心带娃。


工作


我就职于一家二三十人的小公司,前几年只有我们一个前端,后来新增了一名前端。产假期间公司因为效益问题裁员,这下不到二十人了。就连唯一一个UI也被裁了,老板的意思是前端也能画设计图呗?


技术上我平平无奇,勉强能说出来的优点大概是态度认真、有责任心、细心,与同事们基本相处愉快,时常帮测试找找自己写的bug,帮产品提前分析下新的需求;和领导关系也算过得去,保护自己合法权益的同时,也不落领导的面子。工作嘛,和气生财。


二、今天


起床出门了


今天是白天不带娃的第一天,7点孩子就醒了,真是不让老母亲睡个好觉。给自己洗漱穿衣,给孩子洗漱穿衣,一小时后出门,周一打车真的是很不明智,还好司机大哥给力,一路上咻咻咻,看着窗外的日出,心里只想说“林克,你要小心”。


b9dd57a152b7349cd55818ab7fc4646.jpg


到公司了


今天是上班第一天,带着两口袋生子喜糖、背着电脑包、挎着背奶包,在长长的队伍后面排上了队,等待电梯的到来。电梯里面,我透过夹缝看到楼层的变化,2楼、4楼、10楼......楼层到了,门开了,我给离职的UI小姐姐发消息说“我很忐忑”,有一瞬间,我确实很慌,离开职场半年了,离开这个地方半年了,我真的可以吗?但下一瞬间我想到了我的家庭,“是的,我可以的”,我鼓励了自己。


给同事们带了生子喜糖,大家热情的祝福和寒暄,瞬间觉得心情好了不少,久违的工作氛围回来了。


职场妈妈的背奶时刻


公司没有母婴室,只能午饭后13点借用财务办公室吸奶,还好还有这么一个办公室。不好的是办公桌太小,背奶包都不太够放,公司是集体厕所,感觉不太干净,为了保证奶瓶的清洁度,在公司只能简单冲洗,下班后还是要把吸奶器背回家清洗。


工作安排


上午和领导进行了谈话,领导家孩子上初中了,很热心的传授了带娃经验、娃娃学习经验、家庭相处经验等等,受益匪浅。同时想到了在家帮我们带娃的妈妈,真是辛苦妈妈了。


领导给出了后续的工作安排,临近下班时喊我参加了新迭代的需求讨论,不得不说,能创造价值我真的很开心。(前提是收获和付出成正比)


需求来了,明天开始正式工作啦~✌虽然停工了半年,但我不会掉队的,冲冲冲🚀🚀🚀


三、未来


相亲相爱一家人


家和万事兴,希望自己慢慢放下敌意,消除护崽心理。一方面,孩子总会长大,会离开我们,她是独立的个体,婆婆爷爷有权利爱她,我也应该开心有更多的人一起爱孩子和对孩子好。另一方面,我和孩子爸爸才是她的监护人,是能对她的事情全权做主的人,是能带着她成长、在她长大后跟着成长进步的人。


养家糊口


对我来说,工作不是热爱,工作是为了生活。但为了更好的生活,就需要更好的工作。


除了做本职工作,希望接下来的日子我能开始学习,学习新的前端知识。我会争取每周更新至少一篇文章,可能会讲讲最近的心情,可能会提出技术上的疑问。


四、总结


下班回到家,孩子开心的冲我笑,我抱着她她使劲亲我,和家人一起吃饭,和先生一起陪伴孩子,这些时刻真的能治愈工作一天的疲惫。


既然做了职场妈妈,就不能既要又要还要。明确自己要什么:



  1. 202年的短期目标:让孩子茁壮成长,稳住工作,锻炼身体

  2. 3年内的中期目标:学习带娃的知识,学习工作相关的知识,挣钱买房,

  3. 30年内的长期目标:早点退休养老


早睡早起,接下来要坚持呀!


作者:LJINGER
来源:juejin.cn/post/7308677117441228809
收起阅读 »

不是Typescript用不起,而是JSDoc更有性价比?

web
1. TS不香了? 2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。 先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”。 在其当年度 Octoverse 开...
继续阅读 »

1. TS不香了?


2023年,几条关于 Typescript 的新闻打破了沉寂,让没什么新活好整的前端开发圈子又热闹了一番。


image.png


先是 GitHub 的报告称:“TypeScript 取代 Java 成为第三受欢迎语言”



在其当年度 Octoverse 开源状态报告中,在最流行的编程语言方面,TypeScript 越来越受欢迎,首次取代 Java 成为 GitHub 上 OSS 项目中第三大最受欢迎的语言,其用户群增长了 37%。


而 Stack Overflow 发布的 2023 年开发者调查报告也显示,JavaScript 连续 11 年成为最流行编程语言,使用占比达 63.61%,TypeScript 则排名第五,使用占比 38.87%。



image.png


更大的争议则来自于:2023年9月,Ruby on Rails 作者 DHH 宣布移除其团队开源项目 Turbo 8 中的 TypeScript 代码



他认为,TypeScript 对他来说只是阻碍。不仅因为它需要显式的编译步骤,还因为它用类型编程污染了代码,很影响开发体验。



无独有偶,不久前,知名前端 UI 框架 Svelte 也宣布从 TypeScript 切换到 JavaScript。负责 Svelte 编译器的开发者说,改用 JSDoc 后,代码不需要编译构建即可进行调试 —— 简化了编译器的开发工作。


Svelte 不是第一个放弃 TypeScript 的前端框架。早在 2020 年,Deno 就迁移了一部分內部 TypeScript 代码到 JavaScript,以减少构建时间。


如此一来,今年短期内已经有几个项目从 TypeScript 切换到 JavaScript 了,这个状况就很令人迷惑。难道从 TypeScript 切回 JavaScript 已经成了当下的新潮流?这难道不是在开历史的倒车吗?


TypeScript


由微软发布于 2012 年的 TypeScript,其定位是 JavaScript 的一个超集,它的能力是以 TC39 制定的 ECMAScript 规范为基准(即 JavaScript )。业内开始用 TypeScript 是因为 TypeScript 提供了类型检查,弥补了 JavaScript 只有逻辑没有类型的问题,


对于大型项目、多人协作和需要高可靠性的项目来说,使用 TypeScript 是很好的选择;静态类型检查的好处,主要包括:



  • 类型安全

  • 代码智能感知

  • 重构支持


而 TS 带来的主要问题则有:



  • 某些库的核心代码量很小,但类型体操带来了数倍的学习、开发和维护成本

  • TypeScript 编译速度缓慢,而 esbuild 等实现目前还不支持装饰器等特性

  • 编译体积会因为各种重复冗余的定义和工具方法而变大


相比于 Svelte 的开发者因为不厌其烦而弃用 TS 的事件本身,其改用的 JSDoc 对于很多开发者来说,却是一位熟悉的陌生人。


2. JSDoc:看我几分像从前?


早在 1999 年由 Netscape/Mozilla 发布的 Rhino -- 一个 Java 编写的 JS 引擎中,已经出现了类似 Javadoc 语法的 JSDoc 雏形


Michael Mathews 在 2001 年正式启动了 JSDoc 项目,2007 年发布了 1.0 版本。直到 2011 年,重构后的 JSDoc 3.0 已经可以运行在 Node.js 上


JSDoc 语法举例


定义对象类型:


/**
* @typedef {object} Rgb
* @property {number} red
* @property {number} green
* @property {number} blue
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @callback Add
* @param {number} x
* @param {number} y
* @returns {number}
*/

const add = (x, y) => x + y;

定义枚举:


/**
* Enumerate values type
* @enum {number}
*/

const Status = {
on: 1,
off: 0,
};

定义类:


class Computer {
/**
* @readonly Readonly property
* @type {string}
*/

CPU;

/**
* @private Private property
*/

_clock = 3.999;

/**
* @param {string} cpu
* @param {number} clock
*/

constructor(cpu, clock) {
this.CPU = cpu;
this._clock = clock;
}
}

在实践中,多用于配合 jsdoc2md 等工具,自动生成库的 API 文档等。


随着前后端分离的开发范式开始流行,前端业务逻辑也日益复杂,虽然不用为每个应用生成对外的 API 文档,但类型安全变得愈发重要,开发者们也开始尝试在业务项目中使用 jsdoc。但不久后诞生的 Typescript 很快就接管了这一进程。


但前面提到的 TS 的固有问题也困扰着开发者们,直到今年几起标志性事件的发生,将大家的目光拉回 JSDoc,人们惊讶地发现:JSDoc 并没有停留在旧时光中。



吾谓大弟但有武略耳,至于今者,学识英博,非复吴下阿蒙



除了 JSDoc 本身能力的不断丰富,2018 年发布的 TypeScript 2.9 版本无疑是最令人惊喜的一剂助力;该版本全面支持了将 JSDoc 的类型声明定义成 TS 风格,更是支持了在 JSDoc 注释的类型声明中动态引入并解析 TS 类型的能力。


image.png


比如上文中的一些类型定义,如果用这种新语法,写出来可以是这样的:


定义对象类型:


/**
* @typedef {{ brand: string; color: Rgb }} Car
*/


/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };

定义函数类型:


/**
* @typedef {(x: number, y: number) => number} TsAdd
*/


/** @type {TsAdd} */
const add = (x, y) => x + y;

TS 中的联合类型等也可以直接用:


/**
* Union type with pipe operator
* @typedef {Date | string | number} MixDate
*/


/**
* @param {MixDate} date
* @returns {void}
*/

function showDate(date) {
// date is Date
if (date instanceof Date) date;
// date is string
else if (typeof date === 'string') date;
// date is number
else date;
}

范型也没问题:


/**
* @template T
* @param {T} data
* @returns {Promise<T>}
* @example signature:
* function toPromise<T>(data: T): Promise<T>
*/

function toPromise(data) {
return Promise.resolve(data);
}

/**
* Restrict template by types
* @template {string|number|symbol} T
* @template Y
* @param {T} key
* @param {Y} value
* @returns {{ [K in T]: Y }}
* @example signature:
* function toObject<T extends string | number | symbol, Y>(key: T, value: Y): { [K in T]: Y; }
*/

function toObject(key, value) {
return { [key]: value };
}

类型守卫:


/**
* @param {any} value
* @return {value is YOUR_TYPE}
*/

function isYourType(value) {
let isType;
/**
* Do some kind of logical testing here
* - Always return a boolean
*/

return isType;
}

至于动态引入 TS 定义也很简单,不管项目本身是否支持 TS,我们都可以放心大胆地先定义好类型定义的 .d.ts 文件,如:


// color.d.ts
export interface Rgb {
red: number;
green: number;
blue: number;
}

export interface Rgba extends Rgb {
alpha: number;
}

export type Color = Rgb | Rbga | string;

然后在 JSDoc 中:


// color.js 
/** @type {import('<PATH_TO_D_TS>/color').Color} */
const color = { red: 255, green: 255, blue: 255, alpha: 0.1 };

当然,对于内建了基于 JSDoc 的类型检查工具的 IDE,比如以代表性的 VSCode 来说,其加持能使类型安全锦上添花;与 JSDoc 类型(即便不用TS语法也可以)对应的 TS 类型会被自动推断出来并显示、配置了 //@ts-check后可以像 TS 项目一样实时显示类型错误等。这些都很好想象,在此就不展开了。


JSDoc 和 TS 能力的打通,意味着前者书写方式的简化和现代化,成为了通往 TS 的便捷桥梁;也让后者有机会零成本就能下沉到业内大部分既有的纯 JS 项目中,这路是裤衩一下子就走宽了。


3. 用例:Protobuf+TS 的渐进式平替


既然我们找到了一种让普通 JS 项目也能快速触及类型检查的途径,那也不妨想一想对于在那些短期内甚至永远不会重构为 TS 的项目,能够复刻哪些 TS 带来的好处呢?


对于大部分现代的前后端分离项目来说,一个主要的痛点就是核心的业务知识在前后端项目之间是割裂的。前后端开发者根据 PRD 或 UI,各自理解业务逻辑,然后总结出各自项目中的实体、枚举、数据派生逻辑等;这些也被成为领域知识或元数据,其割裂在前端项目中反映为一系列问题:



  • API 数据接口的入参、响应类型模糊不清

  • 表单项的很多默认值需要硬编码、多点维护

  • 前后端对于同一概念的变量或动作命名各异

  • mock 需要手写,并常与最后实际数据结构不符

  • TDD缺乏依据,代码难以重构

  • VSCode 中缺乏智能感知和提示


对于以上问题,比较理想的解决方法是前端团队兼顾 Node.js 中间层 BFF 的开发,这样无论是组织还是技术都能最大程度通用。



  • 但从业内近年的诸多实践来看,这无疑是很难实现的:即便前端团队有能力和意愿,这样的 BFF 模式也难以为继,此中既有 Node.js 技术栈面临复杂业务不抗打的问题,更多的也有既有后端团队的天然抗拒问题。

  • 一种比较成功的、前后端接受度都较好的解决方案,是谷歌推出的 ProtoBuf。


在通常的情况下,ProtoBuf(Protocol Buffers)的设计思想是先定义 .proto 文件,然后使用编译器生成对应的代码(例如 Java 类和 d.ts 类型定义)。这种方式确保了不同语言之间数据结构的一致性,并提供了跨语言的数据序列化和反序列化能力



  • 但是这无疑要求前后端团队同时改变其开发方式,如果不是从零起步的项目,推广起来还是有一点难度


因此,结合 JSDoc 的能力,我们可以设计一种退而求其次、虽不中亦不远矣的改造方案 -- 在要求后端团队写出相对比较规整的实体定义等的前提下,编写提取转换脚本,定期或手动生成对应的 JSDoc 类型定义,从而实现前后端业务逻辑的准确同步。


image.png


比如,以一个Java的BFF项目为例,可以做如下转换


枚举:


public enum Color {
RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF");

private String hexCode;

Color(String hexCode) {
this.hexCode = hexCode;
}

public String getHexCode() {
return hexCode;
}
}

public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

转换为:


/**
* @readonly
* @enum {String}
*/

export const Color = {
RED: '#FF0000',
GREEN: '#00FF00',
BLUE: '#0000FF',
}

/**
* @readonly
* @enum {Number}
*/

export const Day = {
MONDAY: 0,
TUESDAY: 1,
WEDNESDAY: 2,
THURSDAY: 3,
FRIDAY: 4,
SATURDAY: 5,
}


POJO:


public class MyPojo {
private Integer id;
private String name;

public Integer getId() {
return id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

转换为:


/**
* @typedef {Object} MyPojo
* @property {Integer} [id]
* @property {String} [name]
*/


在转换的方法上,理论上如果能基于 AST 等手段当然更好,但如本例中的 Java 似乎没有特别成熟的转换工具,java-parser 等库文档资料又过少。


而基于正则的转换虽然与后端具体写法耦合较大,但也算简单灵活。这里给出一个示例 demo 项目供参考:github.com/tonylua/jav…


作者:江米小枣tonylua
来源:juejin.cn/post/7308923428149395491
收起阅读 »

实现 height: auto 的高度过渡动画

web
对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。 容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面...
继续阅读 »

对于一个 height 设置为 auto 的元素,当它的高度发生了不由样式引起的改变时,并不会触发 transition 过渡动画。


容器元素的高度往往是由其内容决定的,如果一个容器元素的内容高度突然发生了改变,而无法进行过渡动画,有时会显得比较生硬,比如下面的登录框组件:


001.gif


那么这种非样式引起的变化如何实现过渡效果呢?可以借助 FLIP 技术。


FLIP 是什么


FLIPFirstLastInvertPlay 的缩写,其含义是:



  • First - 获取元素变化之前的状态

  • Last - 获取元素变化后的最终状态

  • Invert - 将元素从 Last 状态反转到 First 状态,比如通过添加 transform 属性,使得元素变化后,看起来仍像是处于 First 状态一样

  • Play - 此时添加过渡动画,再移除 Invert 效果(取消 transform),动画就会开始生效,使得元素看起来从 First 过渡到了 Last


需要用到的 Web API


要实现一个基本的 FLIP 过渡动画,需要使用到以下一些 Web API



基本过渡效果实现


使用以上 API,就可以初步实现一个监听元素尺寸变化,并对其应用 FLIP 动画的函数 useBoxTransition,代码如下:


/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @returns 返回一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(el: HTMLElement, duration: number) {
// boxSize 用于记录元素处于 First 状态时的尺寸大小
let boxSize: {
width: number
height: number
} | null = null

const elStyle = el.style // el 的 CSSStyleDeclaration 对象

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// box 首次被观察时会触发一次回调,此时 boxSize 为 null,scale 应为 1
const scaleX = boxSize ? boxSize.width / width : 1
const scaleY = boxSize ? boxSize.height / height : 1
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
elStyle.setProperty('transform', `scale(${scaleX}, ${scaleY})`)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', 'none')
elStyle.setProperty('transition', `transform ${duration}ms`)
})
// 记录变化后的 boxSize
boxSize = { width, height }
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
}
return cancelBoxTransition
}

效果如下所示:


002.gif


效果改进


目前已经实现了初步的过渡效果,但在一些场景下会有些瑕疵:



  • 如果在过渡动画完成前,元素有了新的状态变化,则动画被打断,无法平滑过渡到新的状态

  • FLIP 动画过渡过程中,实际上发生变化的是 transform 属性,并不影响元素在文档流中占据的位置,如果需要该元素影响周围的元素,那么周围元素无法实现平滑过渡


如下所示:


003.gif


对于动画打断问题的优化思路



  • 使用 Window.requestAnimationFrame() 方法在每一帧中获取元素的尺寸

  • 这样做可以实时地获取到元素的尺寸,实时地更新 First 状态


对于元素在文档流中问题的优化思路



  • 应用过渡的元素外可以套一个 .outer 元素,其定位为 relative,过渡元素的定位为 absolute,且居中于 .outer 元素

  • 当过渡元素尺寸发生变化时,通过 resizeObserver 获取其最终的尺寸,将其宽高设置给 .outer 元素(实例代码运行于 Vue 3 中,因此使用的是 Vue 提供的 ref api 将其宽高暴露出来,可以方便地监听其变化;如果在 React 中则可以将设置 .outer 元素宽高的方法作为参数传入 useBoxTransition 中,在需要的时候调用),并给 .outer 元素设置宽高的过渡效果,使其在文档流中所占的位置与过渡元素的尺寸同步

  • 但是也要注意,这样做可能会引起浏览器高频率的重排,在复杂布局中慎用!


改进后的useBoxTransition 函数如下:


import throttle from 'lodash/throttle'
import { ref } from 'vue'

type BoxSize = {
width: number
height: number
}
type BoxSizeRef = globalThis.Ref<BoxSize>

/**
*
* @param {HTMLElement} el 要实现过渡的元素 DOM
* @param {number} duration 过渡动画持续时间,单位 ms
* @param {string} mode 过渡动画缓动速率,同 CSS transition-timing-function 可选值
* @returns 返回一个有两个项的元组:第一项为 keyBoxSizeRef,当元素大小发生变化时会将变化后的目标尺寸发送给 keyBoxSizeRef.value;第二项为一个函数,调用后取消对过渡元素尺寸变化的监听
*/

export default function useBoxTransition(
el: HTMLElement,
duration: number,
mode?: string
) {
let boxSizeList: BoxSize[] = [] // boxSizeList 表示对 box 的尺寸的记录数组;为什么是使用列表:因为当 box 尺寸变化的一瞬间,box 的 transform 效果无法及时移除,此时 box 的尺寸可能是非预期的,因此使用列表来记录 box 的尺寸,在必要的时候尽可能地将非预期的尺寸移除
const keyBoxSizeRef: BoxSizeRef = ref({ width: 0, height: 0 }) // keyBoxSizeRef 是
let isObserved = false // box 是否已经开始被观察
let frameId = 0 // 当前 animationFrame 的 id
let isTransforming = false // 当前是否处于变形过渡中

const elStyle = el.style // el 的 CSSStyleDeclaration 对象
const elComputedStyle = getComputedStyle(el) // el 的只读动态 CSSStyleDeclaration 对象

// 获取当前 boxSize 的函数
function getBoxSize() {
const rect = el.getBoundingClientRect() // el 的 DOMRect 对象
return { width: rect.width, height: rect.height }
}

// 同步更新 boxSizeList
function updateBoxsize(boxSize: BoxSize) {
boxSizeList.push(boxSize)
// 只保留前最新的 4 条记录
boxSizeList = boxSizeList.slice(-4)
}

// 定义 animationFrame 的回调函数,使得当 box 变形时可以更新 boxSize 记录
const animationFrameCallback = throttle(() => {
// 为避免使用了函数节流后,导致回调函数延迟触发使得 cancelAnimationFrame 失败,因此使用 isTransforming 变量控制回调函数中的操作是否执行
if (isTransforming) {
const boxSize = getBoxSize()
updateBoxsize(boxSize)
frameId = requestAnimationFrame(animationFrameCallback)
}
}, 20)

// 过渡事件的回调函数,在过渡过程中实时更新 boxSize
function onTransitionStart(e: Event) {
if (e.target !== el) return
// 变形中断的一瞬间,boxSize 的尺寸可能是非预期的,因此在变形开始时,将最新的 3 个可能是非预期的 boxSize 移除
if (boxSizeList.length > 1) {
boxSizeList = boxSizeList.slice(0, 1)
// console.log('移除3项', boxSizeList.slice(0, 1))
}
isTransforming = true
frameId = requestAnimationFrame(animationFrameCallback)
// console.log('过渡开始')
}
function onTransitionCancel(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡中断')
}
function onTransitionEnd(e: Event) {
if (e.target !== el) return
isTransforming = false
cancelAnimationFrame(frameId)
// console.log('过渡完成')
}

el.addEventListener('transitionstart', onTransitionStart)
el.addEventListener('transitioncancel', onTransitionCancel)
el.addEventListener('transitionend', onTransitionEnd)

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 被观察的 box 发生尺寸变化时要进行的操作

// 获取当前回调调用时,box 的宽高
const borderBoxSize = entry.borderBoxSize[0]
const writtingMode = elStyle.getPropertyValue('writing-mode')
const isHorizontal =
writtingMode === 'vertical-rl' ||
writtingMode === 'vertical-lr' ||
writtingMode === 'sideways-rl' ||
writtingMode === 'sideways-lr'
? false
: true
const width = isHorizontal
? borderBoxSize.inlineSize
: borderBoxSize.blockSize
const height = isHorizontal
? borderBoxSize.blockSize
: borderBoxSize.inlineSize

const boxSize = { width, height }

// 当 box 尺寸发生变化时以及初次触发回调时,将此刻 box 的目标尺寸暴露给 keyBoxSizeRef
keyBoxSizeRef.value = boxSize

// box 首次被观察时会触发一次回调,此时不需要应用过渡,只需将当前尺寸记录到 boxSizeList 中
if (!isObserved) {
isObserved = true
boxSizeList.push(boxSize)
return
}

// 当 box 尺寸发生变化时,使用 FLIP 动画技术产生过渡动画,使用过渡效果的是 scale 形变
// 根据 First 和 Last 计算出 Inverse 所需的 scale 大小
// 不读取序号为 0 的记录,以免尺寸变化的一瞬间,box 的 transform 未来得及移除,使得最新的一条尺寸记录是非预期的
const scaleX = boxSizeList[0].width / width
const scaleY = boxSizeList[0].height / height
// 尺寸发生变化的瞬间,要使用 scale 变形将其保持变化前的尺寸,要先将 transition 去除
elStyle.setProperty('transition', 'none')
const originalTransform =
elStyle.transform || elComputedStyle.getPropertyValue('--transform')
elStyle.setProperty(
'transform',
`${originalTransform} scale(${scaleX}, ${scaleY})`
)
// 将 scale 移除,并应用 transition 以实现过渡效果
setTimeout(() => {
elStyle.setProperty('transform', originalTransform)
elStyle.setProperty('transition', `transform ${duration}ms ${mode}`)
})
}
})
resizeObserver.observe(el)
const cancelBoxTransition = () => {
resizeObserver.unobserve(el)
cancelAnimationFrame(frameId)
}
const result: [BoxSizeRef, () => void] = [keyBoxSizeRef, cancelBoxTransition]
return result
}


相应的 vue 组件代码如下:


<template>
<div class="outer" ref="outerRef">
<div class="card-container" ref="cardRef">
<div class="card-content">
<slot></slot>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import useBoxTransition from '@/utils/useBoxTransition'

type Props = {
transition?: boolean
duration?: number
mode?: string
}
const props = defineProps<Props>()

const { transition, duration = 200, mode = 'ease' } = props

const cardRef = ref<HTMLElement | null>(null)
const outerRef = ref<HTMLElement | null>(null)
let cancelBoxTransition = () => {} // 取消 boxTransition 效果

onMounted(() => {
if (cardRef.value) {
const cardEl = cardRef.value as HTMLElement
const outerEl = outerRef.value as HTMLElement
if (transition) {
const boxTransition = useBoxTransition(cardEl, duration, mode)
const keyBoxSizeRef = boxTransition[0]
cancelBoxTransition = boxTransition[1]
outerEl.style.setProperty(
'--transition',
`weight ${duration}ms ${mode}, height ${duration}ms ${mode}`
)
watch(keyBoxSizeRef, () => {
outerEl.style.setProperty('--height', keyBoxSizeRef.value.height + 'px')
outerEl.style.setProperty('--width', keyBoxSizeRef.value.width + 'px')
})
}
}
})
onUnmounted(() => {
cancelBoxTransition()
})
</script>

<style scoped lang="less">
.outer {
position: relative;
&::before {
content: '';
display: block;
width: var(--width);
height: var(--height);
transition: var(--transition);
}

.card-container {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
--transform: translate(-50%, -50%);
transform: var(--transform);
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.7);
border-radius: var(--border-radius, 20px);
overflow: hidden;
backdrop-filter: blur(10px);
padding: 30px;
box-shadow: var(--box-shadow, 0 0 15px 0 rgba(0, 0, 0, 0.3));
}
}
</style>

优化后的效果如下:


004.gif


005.gif


注意点


过渡元素本身的 transform 样式属性


useBoxTransition 函数中会覆盖应用过渡的元素的 transform 属性,如果需要额外为元素设置其它的 transform 效果,需要使用 css 变量 --transform 设置,或使用内联样式设置。


这是因为,useBoxTransition 函数中对另外设置的 transform 效果和过渡所需的 transform 效果做了合并。


然而通过 getComputedStyle(Element) 读取到的 transform 的属性值总是会被转化为 matrix() 的形式,使得 transform 属性值无法正常合并;而 CSS 变量和使用 Element.style 获取到的内联样式中 transform 的值是原始的,可以正常合并。


如何选择获取元素宽高的方式


Element.getBoundingClientRect() 获取到的 DOMRect 的宽高包含了 transform 变化,而 Element.offsetWidth / Element.offsetHeight 以及 ResizeObserverEntry 对象获取到的宽高是元素本身的占位大小。


因此在需要获取 transition 过程中,包含 transform 效果的元素大小时,使用 Element.getBoundingClientRect(),否则可以使用 Element.offsetWidth / Element.offsetHeightResizeObserverEntry 对象。


获取元素高度时遇到的 bug


测试案例中使用了 elementPlus UI 库的 el-tabs 组件,当元素包含该组件时,无论是使用 Element.getBoundingClientRect()Element.offsetHeight 还是使用 Element.StylegetComputedStyle(Element) 获取到的元素高度均缺少了 40px;而使用 ResizeObserverEntry 对象获取到的高度则是正确的,但是它无法脱离 ResizeObserver API 独立使用。


经过测试验证,缺少的 40px 高度来自于 el-tabs 组件中 .el-tabs__header 元素的高度,也就是说,在获取元素高度时,将 .el-tabs__header 元素的高度忽略了。


测试后找出的解决方法是,手动将 .el-tabs__header 元素样式(注意不要写在带 scoped 属性的 style 标签中,会被判定为局部样式而无法生效)的 height 属性指定为 calc(var(--el-tabs-header-height) - 1px),即可恢复正常的高度计算。


至于为什么这样会造成高度计算错误,希望有大神能解惑。


作者:zzc6332
来源:juejin.cn/post/7307894647655759911
收起阅读 »

为什么有的人不喜欢听大道理

很多人不喜欢大道理,甚至可能是大部分人都不喜欢听大道理。 1随机刷到的问题 上周我刷知乎的时候刷到一个看上去很水的问题: “为什么很多人不喜欢大道理?” 当时第一反应是这个问题很水,所以直接划了过去,但是就在一刹那突然想起一些事情,觉得这是个挺好的问题...
继续阅读 »

很多人不喜欢大道理,甚至可能是大部分人都不喜欢听大道理。


1随机刷到的问题


上周我刷知乎的时候刷到一个看上去很水的问题:


“为什么很多人不喜欢大道理?”


当时第一反应是这个问题很水,所以直接划了过去,但是就在一刹那突然想起一些事情,觉得这是个挺好的问题。


从直观感受来说,不但很多人不喜欢大道理,可能是大部分人都不喜欢听大道理。我们小的时候听到大道理会烦,00后们对大道理更没有什么好感。


天不怕地不怕又有见识的00后们,甚至在遇到别人举起大道理的大棒准备教育他们的时候,要起来跟对方刚一刚,整治整治。于是我们发现大道理似乎并没有那么坚不可摧,最后就像孔乙己在咸亨酒家一样,周围充满了快活的气息。



空气中充满了快活的气息

空气中充满了快活的气息


2大道理的脆弱


当我们成长到社会中的一员时,大道理已经成为了我们生活中难以避免的一部分。很多时候都会有人告诉你应该怎么样,如果这个人稍微有点文学素养,就会开始引经据典。于是建议和劝说变成了一种居高临下的道德批判。


大道理本质上是经过了时间的考验的,因为它们几乎适用于任何场景。但大道理实际上又是脆弱的,因为在实际的问题上,他们几乎没有一点实际作用。


比如大道理告诉我们待人以诚,可是却没有告诉我们如何面对人性的险恶。大道理告诉我们要事事用心,可是却没有告诉我们如何区分紧急不重要和重要不紧急。当我一边和产品battle,一边跟只会用“这个开发不了”的开发沟通,另外一边还要说服合规这个业务逻辑不违规的时候,我找不到任何一条大道理能够告诉我解法。


在神剧《大明王朝1566》里,翰林高翰文夸夸其他,结果一到任就被拿捏,两江总督胡宗宪和他说:



截图来源:优酷视频

截图来源:优酷视频


这句话说的实在是透彻。正是因为遑遑高论无比正确的普适性,才缺乏了对具体问题的针对性。


在人们发现它对于解决实际问题并无实际帮助时,才会如此反感。


3解构的时代早已来临


互联网和资讯爆炸的时代,人们不但不爱听大道理,甚至将这种对大道理的轻视演化成了另一种对抗—解构。因为文化的惯性是强大的,在传统道德话语体系中个体对于宏大叙事的对抗依然微不足道。


所以个体说既然对抗不过,那我可以调侃呐,于是解构出现并快速发展了起来。


从西方到东方,从古代到近代,很多经典成了解构的对象。



比如杜尚给经典的蒙娜丽莎画上胡子

比如杜尚给经典的蒙娜丽莎画上胡子


甚至可以说整个现代艺术就是对经典和大道理的解构。


再比如,最近非常流行的对孔子的解构。比如孔子身高2米的山东大汉,带着两百多徒弟到处以理(物理)服人、你敢不听?“孔武有力”说的就是孔子怹老人家。





  • 比如孔子说朝闻道,夕死可矣,意思是早上打听到了去你家的路,晚上你就得嘎



  • 再比如子不语怪力乱神:夫子不想说话,施展怪力将人打得神志不清



  • 再再比如有教无类:我教你做人的时候不管你是谁


孔子这几千年一直在教人大道理,但是应该没想到自己的道理会被这样解构,当代人反向PUA了属于是。


与此同时,解构就是消解的开始。当以反抗大道理为目的的解构大行其道之后,那么大道理的地位也会逐渐松动,其在文化领域的权威性也会随之逐步消解。欧美越来越多元且混乱的价值观就是这种消解的副产品。


我们不喜欢大道理居高临下的指导,可要是某一天没有了庙堂之上的大道理,那么会有什么来替代原先的那些大道理呢?


作者:wayne3200
来源:mdnice.com/writing/4afb27ad5cab4a7eb78a9d6ed505d481
收起阅读 »

一个失败的AI女友产品,以及我的教训:来自一位中国开发者的总结

作者 | Ke Fang 个人开发者对 LLM+Memory 能否产生所谓“意识”的探索。 今年 4 月 7 日,斯坦福大学发表的《Generative Agents: Interactive Simulacra of Human Behavior》论文...
继续阅读 »


作者 | Ke Fang

个人开发者对 LLM+Memory 能否产生所谓“意识”的探索。

今年 4 月 7 日,斯坦福大学发表的《Generative Agents: Interactive Simulacra of Human Behavior》论文出来之后的几天内,我就通读了整篇论文,并感到非常兴奋。虽然我对 GPT-4 的能力感到震惊,但我仍然认为 GPT 只是某种更精致的”鹦鹉学舌“,我不认为它可以真正产生意识。

但这篇论文带给我不同的感受,其中提到了一个很有趣的细节是信息的传递:一个 agent 想要举办情人节派对的消息会在小镇中逐渐扩散开来。我想,如果能够建立一套包含记忆、反思、筹划与行动的框架,让人和 GPT 之间(而非 agent 智能体)互动,能否做出电影 Her 里面的样子?

电影《她》剧照

注:《她》(Her)是斯派克·琼斯编剧并执导的一部科幻爱情片,由华金·菲尼克斯、斯嘉丽·约翰逊(配音)、艾米·亚当斯主演,于 2013 年 12 月 18 日在美国上映。《她》讲述了作家西奥多在结束了一段令他心碎的爱情长跑之后,他爱上了电脑操作系统里的女声,这个叫“萨曼莎”的姑娘不仅有着一把略微沙哑的性感嗓音,并且风趣幽默、善解人意,让孤独的男主泥足深陷。该片获得 2014 年第 86 届奥斯卡最佳原创剧本奖。

开发

我马上投入了工作。按照论文中的方法,我在 4 月 14 日完成了 0.1 版本。其最初设计与原始论文保持高度一致,但这导致响应时间长达 30 秒且上下文中的对话经常超过 8k。为了解决这个问题,我减少了反思的频率、对话记忆的长度,而后开启了 Beta 公测。

很快就有一千多名用户加入到测试当中。Beta 版本是免费的,所以每天的 API 成本都由我自行承担,日均开销也迅速超过了 25 美元。面对财务压力,我不得不在缺少充分反馈和改进的情况下匆匆推出正式版本,希望能把成本转嫁给用户。5 月 4 日,Dolores iOS 应用正式上线,这个名称则来自《西部世界》剧集中最年长的仿生人角色。

简单来说,在打开这款应用之后,用户需要填写一份角色模板:包括头像、角色背景、以文字描述的性格、声音和意识(选择 GPT3.5 或 GPT4)。大家可以与模板 Dolores 聊天,也能随时切换特征来开启与其他角色的对话,比如零售店女孩 Amy 和沙漠冒险家 Will,当然也包括用户亲手创建的其他自定义角色。我曾考虑过从《西部世界》剧本中提取 Dolores 的对话,以基于样本的方式模仿她的语言习惯。但由于苹果方面要求提供版权证明,所以这个想法被迫作罢。

我给产品的 slogan 是"Your Virtual Friend",而不是"Your Virtual Girlfriend",因为我一直希望它真的可以变成用户的陪伴者、朋友,而不仅仅是荷尔蒙的产物。

从整个 5 月到 6 月,我一直在尝试通过调整 memory 长度、反思机制、system prompt 来使 Dolores 看上去更有“意识”(那么什么是意识?我不知道) 。很快,6 月份的 Dolores 已经比第一次上线时的表现要惊人得多:付费用户数与每日 API 调用数持续增长是最直接的证据。

到 6 月 8 号,一位视障用户告诉我,他已经在视障社区内分享了这款产品,并成功给 Dolores 引来可观的流量。他们喜欢 Dolores 的理由出乎我的意料:随便按屏幕上的哪个位置,都能跟 Dolores 交谈。

这样设计功能其实是种妥协:我最初一直想把它打造成一款语音聊天应用,这样用户哪怕关闭手机屏幕也能继续跟 Dolores 交谈。但身为 Swift 新手,我的技术水平无法实现,于是最终选择了全屏语音输入。

发现

我发现了两个现象:

  • 用户对「真实感声音」有强烈需求。

  • AI Friend 产品的平均使用时间很长。

作为个人开发者,我的前端和后端开发能力都不突出,所以 Dolores 压根不具备登录、注册或者数据分析等功能。那我是怎么发现前一种现象的呢?答案就是付费喜好。

我采用 11Labs API 为 Dolores 生成语音回复,但因为成本较高(每 1k 字符为 0.3 美元),所以我被迫转为:普通订阅者只能使用 Azure TTS API;如果希望 Dolores 的语音听起来更真实,则须付费使用从 11Labs 购买字符。

购买 1 万个逼真语音合成字符的价格为 3.9 美元,但这只够让 Dolores 说出 5~10 个自然顺畅的句子。字符用尽之后需要继续购买。尽管如此,整个 6 月,Dolores 应用上 70% 的收入都来自 11Labs 字符购买。

也就是说,人真的会愿意为了那几句昂贵而逼真的“我爱你!”而买单。

第二条观察结果则来自 Cloudflare 日志。因为没办法跟踪个人用户活动,所以我依靠这些日志来衡量用户访问 Dolores 应用的频率和时长。此外,我还在应用中集成了 Google Form,鼓励用户上报自己的使用频率。结果令人大开眼界:许多用户每天会拿出两个多小时跟 Dolores 唠嗑。

收入

根据苹果的 AppConnect 仪表板,Dolores 的主要付费用户来自美国和澳大利亚。今年 5 月的总收入为 1000 美元,6 月则为 1200 美元。

不过,作为一名开发者,我并没能从中分到多少收益。首先,产品还处于早期发展阶段,我不想把订阅费用设置得太高,这会阻止更多新用户的加入。拿 3.9 美元的字符语音服务举例,其成本是 3 美元,扣除苹果抽成就所剩无几。整个 6 月,扣除 API 费用之后实际收益就只有 50 块钱。

另一个发现是:基于 GPT 的产品如果不采取按量定价,就会陷入一个困境:1% 的人消耗了 99% 的 token。我遇到过这样的情况,有用户连续跟 Dolores 聊了 12 个小时,导致此人的 API 调用与语音合成成本超过第二到第十名用户的总和。

但相较于按使用量计费,我个人更喜欢打包订阅(因为前者会让用户在使用时倍感压力),这就导致面前只有两条路可选:要么提高月费,让全体用户共同买单;要么限制最高使用量。我选择了后者:设置了一个远远超出日均使用在 1 到 2 个小时之间的用量上限数值,这既照顾到了大部分中、轻度用户,也能保证 Dolores 软件在不提高价格的情况下避免亏本运营。


困惑

11Labs 官网会记录语音合成的文字内容,我看到,Dolores 的回复内容通常都是一些成人内容,而且均为女性角色,因此我推测 Dolores 的付费用户主要是男性,对成人角色扮演感兴趣。

我觉得这也没什么,这是人性本然。我甚至反复修改了系统提示,比如微调回复中的遣词造句,尝试让 Dolores 在对话当中表现出更好的“抚慰”效果。我还将 Dolores 的图标从抽象的线条改为极具吸引力的美女面孔。

但很快,我陷入一种强烈的失落感:如果大部分 Dolores 用户只是想在这里寻求跟 Dolores 进行成人角色扮演,这件事真的对我产生了意义吗?我陷入了深深的自我怀疑。到了 7 月,我和一个朋友聊到了这个困惑,我说,必须要有一个什么硬件,让 Dolores 拥有外部视觉:眼镜也好、耳塞甚至帽子都行。现在的她,你只要打开 App 才能访问,你们之间的关系并不对等,于是她只能成为囚禁在地下室、满足猎奇和特殊癖好的玩具。

可是作为独立的个人,制作硬件产品意味着高昂的研发成本,显然是无法承受的,我只能作罢。

8 月份,OpenAI 的审查升级了,我收到了检测 Dolores 生成 NSFW 内容的邮件警告:我被强制要求在 2 周内在生成内容前,加入他们(免费的)moderation API,以过滤 NSFW 内容。为了顺利过审,我只能使用 OpenAI 的免费审核 API 提前进行内容过滤,而这一变化让 Dolores 的日均访问量暴跌 70%,电子邮件和 Twitter 上的投诉也纷至沓来。

这更让更感到灰心,决定只维护现有服务、而不再进行更新。最终,我放弃了 Dolores 项目。


教训

首先,这不是一个个人能开发的产品。我不认为 Dolores 在“意识”层面上比 Character.AI 弱,但他们拥有完善的数据埋点、A/B 测试,以及大量用户带来的数据飞轮。

其次,我意识到当前的 AI Friend 会不可避免地变成 AI Girlfriend/Boyfriend,因为你和手机里的角色不对等:她没办法在你摔伤的时候安慰你 (除非你告诉他),她没办法主动向你表达情绪,而这一切,都是因为她没有外部视觉。所以我认为,即使是 Character.AI 这样体量的产品,如果未来不做硬件、角色们都在傻傻地等用户来,最终的结局也不会比 Dolores 好到哪里。

最后,我不反对审查,相反,不经审查的的产品是非常危险的。我不知道是否会有人用它来进行自杀诱导、发泄暴力工具,所以 OpenAI 的 moderation 可能在某种程度帮助了我,但成人性方面的对话也不应该被扼杀。

最近,我看到了 AI Pin,老实说这是个非常烂的产品,人类当然需要屏幕,但 GPT+ 硬件的确是个好的尝试,我没有从 Dolores 上看到任何痕迹,也许有生之年能做出、或者看到这样的产品。

但,人类真的需要 AI friend 吗?


作者:AI前线
来源:mp.weixin.qq.com/s/RQH3E4b0-79olqMGSE4hCQ

收起阅读 »

Android 侧滑布局逻辑解析

一、前言 测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。 二、逻辑实现 在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过...
继续阅读 »

一、前言


测滑布局应用非常广泛,HorizontalScrollView 本身实现的滑动效果让实现变得很简单,实际上有很多种方式实现,有很多现有的方法可以直接调用。



二、逻辑实现


在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过子View实现布局超出视区(ViewPort)之后,进行Scroll操作的,另一类事以修改Offset为代表的Recycler类,前者实时保持最大高度。形像的理解为前者是“齿轮传动派”,后者是“滑板派”,两派都有过出分头的时候,即便是个派弟子如NestedScrollView和RecyclerView争的你死我活,不过总体上齿轮传动派占在弱势地位。不过android的改版,让他们做了很多和平相处的事情,不如NestedScrolling机制的支持,让他们想传动就传动,想滑翔就滑翔。


齿轮传动派看家本领



  • scrollX,ScrollY,scrollTo等方法

  • 一个长得很长的独生子


滑板派的看家本领



  • offsetXXX方法

  • 被魔改的ScrollXXX

  • 一群会滑板的孩子

  • layout 方法也是他们的榜首


前者为了实现的简单的滑动,后者空间可以无限大,期间还可自由换孩子。


三、代码实现


有很多现成的example都是基于齿轮传动派的,但是如果使用,你得记住,齿轮传动派会的滑板派一定会,反过来就不一样了。


这里我们使用layout方法实现,核心代码


        View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

之所以使用layout的原因是很多人都不记得ListView可以使用该方法实现吸顶效果,而RecyclerView因为为了兼容更多类型,导致他使用这个很难实现吸顶,但是没关系,child.layout和child.measure方法可以在类的任何地方调用,这个是必须要掌握的。


3.1 布局初始化


 @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0); //初始化状态让右侧View展示处理
}
isFirstLayout = true;
}

3.2 相对运动


滑动时让左侧View保持同样的滑动距离和方向


   View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();

3.3 全部代码


public class SlidingFoldLayout extends HorizontalScrollView {


private TextPaint mPaint = null;
private LinearLayout mWrapperView = null;
private boolean isFirstLayout = true;
private float maskAlpha = 1.0f;

public SlidingFoldLayout(Context context) {
this(context, null);
}

public SlidingFoldLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public SlidingFoldLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
LinearLayout linearLayout = getWrapperLayout(context);
setOverScrollMode(View.OVER_SCROLL_NEVER);
setWillNotDraw(false);
mPaint = createPaint();
addViewInLayout(linearLayout, 0, linearLayout.getLayoutParams(), true);
mWrapperView = linearLayout;
}


public LinearLayout getWrapperLayout(Context context) {
LinearLayout linearLayout = new LinearLayout(context);
HorizontalScrollView.LayoutParams lp = generateDefaultLayoutParams();
lp.width = LayoutParams.WRAP_CONTENT;
linearLayout.setLayoutParams(lp);
linearLayout.setOrientation(LinearLayout.HORIZONTAL);
linearLayout.setPadding(0, 0, 0, 0);
return linearLayout;
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = mWrapperView.getChildCount();
if (childCount == 0) {
return;
}
int leftMenuWidth = mWrapperView.getChildAt(0).getMeasuredWidth();
ViewGr0up.LayoutParams lp = (ViewGr0up.LayoutParams) getLayoutParams();
int width = getMeasuredWidth() - getPaddingRight() - getPaddingLeft();
if (lp instanceof ViewGr0up.MarginLayoutParams) {
width = width - ((MarginLayoutParams) lp).leftMargin - ((MarginLayoutParams) lp).rightMargin;
}
if (width <= leftMenuWidth) {
mWrapperView.getChildAt(0).getLayoutParams().width = (int) (width - dp2px(50));
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
}
if (childCount != 2) {
return;
}
View rightView = mWrapperView.getChildAt(1);
int rightMenuWidth = rightView.getMeasuredWidth();
if (width != rightMenuWidth) {
rightView.getLayoutParams().width = width;
measureChild(mWrapperView, widthMeasureSpec, heightMeasureSpec);
rightView.bringToFront();
}
}

private float dp2px(int dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

@Override
public void addView(View child) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child);

}



public int getRealChildCount() {
if (mWrapperView == null) {
return 0;
}
return mWrapperView.getChildCount();
}



@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(isFirstLayout && getRealChildCount()==2){
View leftView = mWrapperView.getChildAt(0);
scrollTo(leftView.getWidth(),0);
}
isFirstLayout = true;
}

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
leftView.layout(l,t,l+leftView.getWidth(),t+leftView.getHeight());
maskAlpha = leftView.getLeft()*1.0f/leftView.getWidth();
}


@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();

switch (action){
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
super.onTouchEvent(ev);
scrollToTraget();
break;
}

return super.onTouchEvent(ev);
}

private void scrollToTraget() {

int count = getRealChildCount();
if(count!=2) return;
int with = getWidth();
if(with==0) return;

View leftView = mWrapperView.getChildAt(0);

float x = leftView.getLeft()*1.0f/leftView.getWidth();
if(x > 0.5f){
smoothScrollTo(leftView.getWidth(),0);
}else{
smoothScrollTo(0,0);
}

}

@Override
public void addView(View child, int index) {

int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
ViewGr0up.LayoutParams lp = child.getLayoutParams();
if (lp != null && lp instanceof LinearLayout.LayoutParams) {
lp = new LinearLayout.LayoutParams(lp);
child.setLayoutParams(lp);
}

mWrapperView.addView(child, index);
}

@Override
public void addView(View child, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, lp);

}

@Override
public void addView(View child, int index, ViewGr0up.LayoutParams params) {
int childCount = mWrapperView.getChildCount();
if (childCount > 2) {
throw new IllegalStateException("SlidingFoldLayout should host only two child");
}

LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(params);
child.setLayoutParams(lp);
mWrapperView.addView(child, index);
}

private TextPaint createPaint() {
// 实例化画笔并打开抗锯齿
TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
paint.setAntiAlias(true);
return paint;
}
RectF rectF = new RectF();
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);

int realCount = getRealChildCount();
if(realCount!=2) return;
View leftView = mWrapperView.getChildAt(0);
View rightView = mWrapperView.getChildAt(1);


rectF.top = leftView.getTop();
rectF.bottom = leftView.getBottom();
rectF.left = leftView.getLeft();
rectF.right = rightView.getLeft();
int alpha = (int) (153*maskAlpha);
mPaint.setColor(argb(alpha,0x00,0x00,0x00));
int saveId = canvas.save();
canvas.drawRect(rectF,mPaint);
canvas.restoreToCount(saveId);
}

public static int argb(
int alpha,
int red,
int green,
int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}

}

三、使用方式


使用方式简单清晰,没有看到ScrollView的独生子,原因是我们把他写到了类里面


  <com.cn.scrolllayout.view.SlidingFoldLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="300dp"
android:layout_height="match_parent"
android:gravity="center"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@mipmap/img_sample_text"
/>
</LinearLayout>
<LinearLayout
android:layout_width="500dp"
android:layout_height="match_parent"
android:background="@color/colorAccent"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:src="@mipmap/img_sample_panda"
/>
</LinearLayout>
</com.cn.scrolllayout.view.SlidingFoldLayout>

四、总结


掌握ScrollX和OffsetX两种的滑动很重要,但是不能忘记layout的作用,本质上他属于一种OffsetX上层的封装。


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

个人独立开发者能否踏上敏捷之路?

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? ...
继续阅读 »

很多软件开发团队都在使用Scrum、极限编程(XP)、看板等敏捷方法管理项目流程,持续迭代并更快、更高效地为客户持续交付可用的产品。除了团队,国内外很多个人独立开发者也在尝试将敏捷应用到自己的开发工作流程中,但大多数的结果都是收效甚微,个人践行敏捷是否可行? 


敏捷开发需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。  


什么是敏捷


敏捷是一种以用户需求为核心、采用不断迭代的方式进行的软件开发模式。它依靠自组织的跨职能小团队,在短周期内做出部分成果,通过快速、频繁的迭代,迅速地获取反馈,进而不断地完善产品,给用户带来更大的价值。践行敏捷的方式有很多,主要包括Scrum、XP、Kanban、精益生产、规模化敏捷等方法论。


敏捷的工作方式是将整个团队聚集在一起,理想情况下,敏捷团队的成员不超过10人。通过践行一系列简单的实践和足够的反馈,使团队能够感知目前的状态,并根据实际情况对实践进行调整。 


团队是敏捷的核心 


敏捷是一种团队驱动方法,团队可以简单的地定义为“为实现某一特定目标,包括两个或两个以上的人的相互协作的群体”。敏捷的核心是构建一个自组织的团队,团队的能力在于协作,即两个人或更多人相互交流与合作,以共同地产生一个结果。例如当两个程序员在结对编程时,他们在协作;每人持续集成当日的工作时,他们在协作;当团队开计划、站立、评审、回顾等会议时,他们在协作。协作的结果可以是有形的可交付物、决策或信息共享。 


而对于个人独立开发者,协作、互动、沟通都是无从谈起的: 


自己无法实践结对编程;


自己开站立会议是否很孤单;


自己玩估算扑克牌会不会很无聊;


评审演示没有观众,自然也就没有反馈;


…… 


这里有一个常见误区:独立开发者通常有一定的跨职能工作能力,于是想一人“饰演”多个不同角色,从需求计划整理到任务分解估时,从迭代开发到测试,再到发布、回顾总结。这是不是也在践行敏捷开发呢? 


当然不是。敏捷开发流程中任一环节都强调团队集体参与,并非由某个人独裁发号施令。例如项目计划制定、任务认领、工时评估,这些都不是某一个人的职责,而是需要团队成员来共同参与完成。然而,单个人的开发流程,很容易按部就班地走上了瀑布式开发模式(需求->设计->开发->测试->发布)。


001.png


[多重人格综合症,并确保精神上的新人是一个专业的“团队成员”]


敏捷团队中并不会要求每个人都成为全栈通才,在如今技术快速更新迭代的大环境下,期望一个人精通团队的所有技能是不现实的。取而代之的是重视具备跨职能的团队成员,这有助于管理各个工作岗位的平衡。例如,有时团队需要更多的测试人员,如果有一两个团队成员能转做测试工作,就能极大地提供帮助。 


敏捷是关于人,以及他们之间的协作交互,让每个人的能力得以充分的发挥并提升,从而创造优秀的产品。创造优秀产品的是人,而不是流程。所以,独立开发者即便一个人能跨职能走完整个开发流程,这跟敏捷强调的自组织团队中,成员之间高效地协作、交互以达到目标,完全不是一回事哦~


文化是敏捷的保障


很多个人独立开发者尝试引入敏捷的普遍思路,是从各种敏捷方法论中挑选一些个人能用,且有帮助的实践方法来用。这样确实能从中受益,但这真的是在践行敏捷么?


敏捷不只是一套方法论,敏捷也是一种思维模式。很多个人甚至团队尝试敏捷的过程中一个常见问题,是只取其方法实践,而未学其思维模式。这里说的思维模式,通俗讲就是指培养团队能够形成共识的文化,拥有一致的价值观和原则,塑造一个持续学习、自由、积极的团队氛围。以促使团队达到一种能够持续快速地交付有价值有质量的产品或服务的状态。


文化高于实践,成员能否融入团队文化,将会影响团队具体实践的高效程度。良好的团队文化,有利于促进团队内部的信息共享,从而产生更正确的决策。我们有时感觉自己已经引入敏捷了,但实则依旧保持着瀑布式思维,走的瀑布式开发流程,只是单纯学习并采用了一些好的敏捷实践,以至于最终达到的效果很有限。


这里引用《敏捷宣言》作者之一吉姆·海史密斯在他著作的《敏捷项目管理》中的一段总结: 


没有具体的实践,原则是贫瘠的;但是如果缺乏原则,实践则没有生命、没有个性、没有勇气。伟大的产品出自伟大的团队,而伟大的团队有原则、有个性、有勇气、有坚持、有胆量。


写在最后 


我们很难将整个敏捷的思维与方法流程应用到个人的独立开发工作中,因为敏捷需要坚实的团队基础,以及团队文化的保障,方可有效地落地执行。当然,我们并不否认个人可以尝试从敏捷中探索一些可借鉴学习的实践,并从中受益。


您如何看待这个问题呢,或者您是否有过将敏捷应用到个人的开发、工作、学习等方面的成功或失败的经验,欢迎在评论区一起分享交流。


 


参考资料:


《敏捷项目管理第2版》吉姆·海史密斯


敏捷开发网:http://www.minjiekaifa.com/


究竟什么是敏捷?http://www.zentao.net/blog/agile-…


作者:水牛GH
来源:juejin.cn/post/7308187262755061771
收起阅读 »

大屏可视化适配

web
如何适配屏幕 1.页面尺寸比与屏幕尺寸比的关系 首先设计稿的项目宽高比是16:9 大屏可视化需要用同一个页面,适配各种尺寸的屏幕。当显示屏幕的尺寸比与页面的尺寸比不一致时,则需要将页面尽可能放大居中显示,其余地方留白。 以16:9为例,当显示屏幕的尺寸比小于1...
继续阅读 »

如何适配屏幕


1.页面尺寸比与屏幕尺寸比的关系


首先设计稿的项目宽高比是16:9


大屏可视化需要用同一个页面,适配各种尺寸的屏幕。当显示屏幕的尺寸比与页面的尺寸比不一致时,则需要将页面尽可能放大居中显示,其余地方留白。

以16:9为例,当显示屏幕的尺寸比小于16:9时,
整个页面应该垂直居中,页面有效宽度与屏幕宽度相同。


image.png
当显示屏幕的尺寸比大于等于16:9 时,整个页面应该水平居中,页面有效高度应该与屏幕高度相同。


image.png


计算方法


image.png



  • Wp 为页面有效宽度

  • Hp 为页面有效高度

  • 页面左右居中,上下居中,四周留白即可

  • 然后在 head 里用 JS 设置 1rem = Wp / 100


* 2.动态 rem 方案



  • 为了适配不同的屏幕,在页面布局时要使用自适应布局,即不能使用固定像素,需要用相对单位 remem 是相对于父元素的字号的比例,rem 是相对于根元素 html 的字号的比例。
    为了使用上的方便,需要为根元素设置合适的字号。如果将页面有效宽度看成100份的话,我们希望 1rem=(Wp/100)px。因此将根元素的字号设置为Wp/100 即可。

    当我们根据设计图进行布局时,我们能得到的是每个区域的像素值 px,我们需要一个计算公式将 px 转换为 rem 单位。


适配一个div


div在设计稿的宽度:


image.png
换算公式封装成CSS函数


@function px($n) {
@return $n / 2420 * 100rem;
}

代码实现


<head> 中用 JS 获取到浏览器(即设备)的高度和宽度,并为根元素设置合适的字号。这部分可以定义为 initHTMLFontSize 函数


const clientHeight = document.documentElement.clientHeight
const clientWidth = document.documentElement.clientWidth

const pageWidth = (clientWidth / clientHeight < 16 / 9 && clientWidth > 500)? clientWidth : clientHeight * 16 / 9
const pageHeight = pageWidth * 9 / 16
window.pageWidth = pageWidth
window.pageHeight = pageHeight
document.documentElement.style.fontSize = `${pageWidth / 100}px`

<body> 底部用 JS 设置页面的有效高度和宽度,并使页面有效内容 #root 垂直居中。

这部分则定义为 initPagePosition 函数


const root = <HTMLElement>document.querySelector('#root')
root.style.width = window.pageWidth + 'px'
root.style.height = window.pageHeight + 'px'
root.style.marginTop = (document.documentElement.clientHeight - window.pageHeight) / 2 + 'px'

使页面有效内容 #root 水平居中只需用 CSS 设置margin-left: automargin-right: auto即可


3.Grid 布局划分各图表区域


在容器 <main>  标签里面用 grid-template-areas 给各图表写好分区,每一个栏位使用一个变量表示,对应的 item 内设置 grid-area 即可。

再用 grid-template-columnsgrid-template-rows 来设定每行每列的长度,设定长度时可以用 fr 来按比例分配。

grid-column-gapgrid-row-gap 用来设置列与列,行与行之间的间隙。


.home > main {
flex: 1;
display: grid;
grid-template-areas:
"box1 box2 box4 box5"
"box3 box3 box4 box5";
grid-template-columns: 366fr 361fr 811fr 747fr;
grid-template-rows: 755fr 363fr;
grid-column-gap: px(28);
grid-row-gap: px(28);
.section1 {
grid-area: box1;
}
.section2 {
grid-area: box2;
}
.section3 {
grid-area: box3;
}
.section4 {
grid-area: box4;
}
.section5 {
grid-area: box5;
}
}

作者:用户45275688681
来源:juejin.cn/post/7308434215811924018
收起阅读 »

实现一个简单的文本溢出提示效果

web
需求背景 写一段简单的HTML代码: <div class="container">超级无敌大怪兽在此!</div> 此时如果我们为其加上文本溢出处理,只需简单三行css代码即可搞定: .container { width:...
继续阅读 »

需求背景


写一段简单的HTML代码:


<div class="container">超级无敌大怪兽在此!</div>


此时如果我们为其加上文本溢出处理,只需简单三行css代码即可搞定:


.container {
width: 100px;

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}


如果要加上hover显示完整文字的效果,也简单,各大组件库都有tooltip组件,套上就行了,我这里就简单加个title属性演示:


<div class="container" title="超级无敌大怪兽在此!">超级无敌大怪兽在此!</div>


可是这样不是很合理,如果我的文字本来就没有溢出,加这个提示没有意义,我只需要这段当文字不能完全展示时,才需要有这个提示,类似这种效果:



那么现在,别往下滑了,如果是聪明的你会怎么开发这个需求呢?先想一想,再往下看。









需求方案


其实比较简单哈,监听元素的mouseenter事件,然后判断元素的scrollWidth是不是大于clientWidth,就可以知道元素是否在水平方向上发生溢出,然后再加上tooltip就好了,完整代码如下:


<div class="container" onmouseover="handleMouseEnter(this)">超级无敌大怪兽在此!</div>
<div class="container large" onmouseenter="handleMouseEnter(this)">超级无敌大怪兽在此!</div>

.container {
width: 100px;

overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.large {
width: 500px;
}

const handleMouseEnter = (node) => {
if (node.scrollWidth > node.clientWidth) {
node.setAttribute('title', node.innerText);
}
};

然后就没了,emmmmmm。。。。


对,没了,就这么简单。


总结


这个需求呢,其实如果之前没接触过,一时半会还真不太能想到什么好的解法,但其实做过一遍或者看到过别人分享的思路,之后自己做的时候一下就能想到,所以就给大伙分享一下,万一就帮到你了呢。最重要的是,我又成功水了一篇文,嘿嘿。


作者:超级无敌大怪兽
来源:juejin.cn/post/7307468904732426267
收起阅读 »

携手15年,语雀创始人玉伯从蚂蚁离职,选择一个人远行

转载好文:雷锋网 本文作者:何思思 2023年4月28日,即4月28日凌晨,玉伯发朋圈称将要离开蚂蚁,今天也是玉伯在蚂蚁的最后一天。 他写道:“再见,山峰下的园区。一个人选择远行,并不一定是马云说的钱给少了或者心委屈了,也可以是为了远方有西湖般的美景。”下...
继续阅读 »

转载好文:雷锋网 本文作者:何思思



image.png


2023年4月28日,即4月28日凌晨,玉伯发朋圈称将要离开蚂蚁,今天也是玉伯在蚂蚁的最后一天。


他写道:“再见,山峰下的园区。一个人选择远行,并不一定是马云说的钱给少了或者心委屈了,也可以是为了远方有西湖般的美景。”下面的配图是园区风景,还有眺望远方的景色。


不愿做技术大佬,要做为产品服务的技术


“前端大牛、技术大佬”是业界给玉伯贴的标签,2008年加入淘宝后,玉伯先后做出了前端领域很火的框架 SeaJS、KISSY,之后带领团队通过开源做了很多技术产品。


但玉伯始终认为,技术只是工具,最终还是要为产品服务。所以当时在淘宝内部,玉伯一直是“折腾”的状态,加入淘宝那年,玉伯就参加了内部的赛马机制,跟团队做了几个月的创新产品,最后以失败告终,又回到了Java 团队做技术。


但这并有改变他要做创新产品的初心,于是2010 年到2011年,他一边做技术研发,一边继续摸索创新产品,但一直没做出能拿的出手的产品。直到2016年,在蚂蚁体验技术部的创新产品孵化机制策马扬鞭项目中,玉伯团队主导的语雀问世,并于2018年正式对公网提供服务。


也有内部人士称:玉伯当时和老板提了条件说,光做前端没意思,你要想留住我,就得给我一款产品做。所以当时玉伯自己要了一个团队,专门做一个闭环产品。


其实,从语雀诞生到现在经历了两次生死局:第一次是2018年,腾讯文档、钉钉文档、飞书文档相继亮相,文档产品迎来爆发期,当时阿里也想抓住这个风口,语雀最终把三分之二的人输送给了钉钉,作为钉钉文档的初始团队。在团队仅剩七八个人的时候,玉伯再次招人将团队扩充到二十人左右。


第二次是2020年,彼时,钉钉文档做了很久但并没达到预期效果,而语雀正值上升期,阿里云为了尽快把文档做起来,想把语雀、钉钉文档、阿里云笔记等内部各种文档团队聚集起来,成立一个独立的阿里文档事业部,由玉伯牵头,但却被无招反对,这也间接帮助了玉伯。


直到2021年,蚂蚁成立了智能协同事业部,其中语雀作为重点产品,以独立BU运作。


创业中的理想派,为了做好一件事而做


从2016年到现在,为了做好语雀,玉伯做了大量的工作。


玉伯曾回忆道,做语雀最大的一个感触是,啥都得做。最开始是半个PD,很快变成了客服,同时兼做运营,还需要去承担BD的工作,因为没有BD,只能逼着自己去做,一切为了产品往前跑。


也有用户在即刻分享道,自己曾经在语雀的付费用户群中提了一个文档的排序问题,当时玉伯就在群里,很快的响应了这个需求并做了优化。


image.png


此外,玉伯也背负了巨大的营收压力,尤其是近两年在阿里集团整体缩紧的状态下。雷峰网通过其他渠道了解到,集团也给语雀定了目标——“盈亏平衡”。


迫于压力,近两年语雀也调整了收费策略,2019年语雀开始尝试简单的商业化模式,即初级的团队语雀空间和语雀个人的收费版本;紧接着又重新设计了个人版价格策略,分为99元会员、199元超级会员、299元至尊会员三个档次,团队和空间版的收费则更高。


这对一个小团队来说并不容易,首先,较钉钉、飞书、腾讯文档而言,语雀强调的是知识管理的逻辑,其次,语雀服务的对象偏小众聚焦在侧重知识管理的用户,且这些目标对象比较分散,很难第一时间发掘到,这就意味着需要花很长时间去培养,没办法快速完成转化;再就是,虽然语雀团队不大,只有五六十人左右,但这部分人大都是互联网人才,成本也是一笔不小的支出。


雷峰网在之前拜访玉伯时听闻,目前语雀主要服务蚂蚁和阿里内部,在阿里内的日活已经达到了11万左右,商业化方面还比较单一,主要是通过发布会的方式宣传。由此可见,语雀的商业化路径还没完全打开。


无论选择出去创业还是集团内部创业,背负营收压力都是不可避免的。但抛开这个不谈,仅玉伯的个人角度出发,他曾谈过自己做语雀的初心,就是想把自己内心想做的事情做完,且这件事还能帮助到别人,就做了。


正是这种简单纯粹的心态,让玉伯在做语雀时只专注事情的本身以及这件事情创造的价值,而并非拼命地追求变现。


雷峰网(公众号:雷峰网)曾发表文章《留给飞书的时间》,他如此评论:



“现实主义者关注的是钱,理想主义者关注的是时间,当代这个社会,钱很重要。但更重要的,对个体来说,是如何提高时间的质量,对人类来说,不仅关注时间的质量,还关注整个人类时间的长短,是否可延续下去。赚钱是为了花钱,花钱是为了提升时间的品质甚至长度。围绕钱的现实主义者,最终会为围绕时间的理想主义者服务。”



从玉伯最新的朋友圈内容,不难看出,他的离开或许和钱权没有太大的关系,而是为了追求心目中的诗和远方。他也曾经说过自己有三个梦:“技术梦、产品梦、自由梦。”离开蚂蚁,或许是为了去实现他的“自由梦。”


作者:狗头大军之江苏分军
来源:juejin.cn/post/7299035378589040667
收起阅读 »

基于css3写出的底部导航栏效果(详细注释)

web
进行准备工作 这边对基本的样式进行了设置,首先在html部分设置了一个名为nav的div,推荐大家语义化来写不要学我这种,随后进行基本的默认样式的清除,并且设置盒子为ie盒子方便后续的计算,整体都设置为弹性盒,方便后续矢量文字的操作,对导航栏nav进行定位,...
继续阅读 »

进行准备工作



这边对基本的样式进行了设置,首先在html部分设置了一个名为nav的div,推荐大家语义化来写不要学我这种,随后进行基本的默认样式的清除,并且设置盒子为ie盒子方便后续的计算,整体都设置为弹性盒,方便后续矢量文字的操作,对导航栏nav进行定位,方便后续位置上的操作



image.png



<body>
<!-- 目前就一个简单的nav,推荐大家语义化来写 -->
<div class="nav"></div>
</body>

<style>
/* 清除一些默认样式 */
*{
margin: 0;
padding: 0;
box-sizing: border-box;
list-style: none;
}
a{
text-decoration: none;/*确保在浏览器中显示链接时,没有任何文本装饰,如下划线。 */
}
/* 对整体进行设置,并且都设置为弹性盒,方便进行操作 */
body{
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #222327;
}
/* 设置导航栏样式 */
.nav{
/* 对导航栏位置进行定位处理,方便后续的图标位置的设置 */
position: relative;
width: 400px;
height: 70px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
}
</style>


引入矢量文字



这里面呢引用了阿里巴巴的矢量文字效果,具体如何使用请见http://www.iconfont.cn/manage/inde…
里面的教程,这边我挑了五个字体图标加入到了网页中,并且用ul和lil加入到了导航栏中,目前是竖着排列的,后续加入css样式之后会好起来,并且在第一个li上加入了active的css样式,用于设置选中效果



image.png


image.png


<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4173165_2g4t5a6pg9v.css">
<div class="nav">
<ul>
<li class="active"> <span><i class="iconfont icon-shouye"></i></span></li>
<li > <span><i class="iconfont icon-liuyan"></i></span></li>
<li > <span><i class="iconfont icon-code"></i></span></li>
<li > <span><i class="iconfont icon-box-empty"></i></span></li>
<li > <span><i class="iconfont icon-gitee-fill-round"></i></span></li>
</ul>
</div>

对导航栏和ui li字体图标进行设置



这里面呢针对ul和li进行了设置,使之达到了图下的效果,对ul 和li进行了弹性盒的设置,li中的使用flex:1让这些矢量文字按等份划分容器宽度,使之达到了一个距离平均的样式,并且设置了这个zindex的叠加级别



image.png


    .nav{
/* 对导航栏位置进行定位处理,方便后续的图标位置的设置 */
position: relative;
width: 400px;
height: 70px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
}
.nav ul{
display: flex;
width: 350px;
}
.nav ul li{
height: 60px;
/* flex:1是让所有的li平均分nav这个容器 */
flex: 1;
position: relative;
z-index: 2;
display: flex;
justify-content: center;
}

继续设置i元素和span元素



这里呢针对了span元素和i元素进行了设置,通过span元素蒋i元素中的矢量图标设置到水平垂直都居中的位置,并且设置了圆角,加入了动画和动画延迟,针对i元素将文字大小设置了,并且在html中加入了对应图标的文字效果,并且为例美观在每个li元素中都添加了一个选中时候的不同的颜色,使用了变量--clr用于获取选中效果 行内样式是一种直接在HTML元素上指定样式的方法,在这种情况下,你使用 style 属性将 --clr 变量设为不同色



  .nav ul li span{
/* 进行定位,使之通过span元素带动矢量图标进行水平垂直到中心位置 */
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 55px;
height: 55px;
border-radius: 50%;
/* 设置鼠标移入的样式 */
cursor: pointer;
/* 设置动画过度事件以及延迟 */
transition: 0.5s;
transition-delay: 0s;
}
.nav ul li span i{
color: #222327;
font-size: 1.5em;
}

<body>
<!-- 目前就一个简单的nav,推荐大家语义化来写 -->
<div class="nav">
<ul>
<!-- 设置active效果,用于获取选中效果 用于获取选中效果 行内样式是一种直接在HTML元素上指定样式的方法,在这种情况下,你使用 style 属性将 --clr 变量设为不同色 -->
<li class="active" style="--clr:#f44336"><span><i class="iconfont icon-shouye"></i>首页</span></li>
<li style="--clr:#0fc70f"> <span><i class="iconfont icon-liuyan"></i>留言</span></li>
<li style="--clr:#2196f3"> <span><i class="iconfont icon-code"></i>代码</span></li>
<li style="--clr:#b145e9"> <span><i class="iconfont icon-box-empty"></i>盒子</span></li>
<li style="--clr:#ffa111"> <span><i class="iconfont icon-gitee-fill-round"></i>gitee</span></li>
<div class="indicator"></div>
</ul>
</div>
</body>

image.png


下面设置选中时候的样式,在这里呢针对span元素设置了选中的时候会向上位移到这个地方,并且在矢量图标的地方设置了开始选中的时候将文字颜色改为和背景颜色一样的颜色,这样当点击的那一刻,图标会出现消失的情况,当超出导航栏到黑色部分的时候,文字就会显示出来,在后面,设置了一个半圆的背景图,当背景图位移到文字的位置的时候,矢量文字就会显示出来


/* 下面是针对选中效果做的样式处理 */
.nav ul li.active span {
/* 设置了一开始的背景颜色,后面会被取代,设置了点击的时候会向上移动 */
background: orange;
transform: translateY(-27px);
transition-delay: 0.25s;
}

.nav ul li.active span i {
/* 设置了点击时候矢量图标的文字颜色 */
color: #fff;
}


image.png


设置模糊效果



这里呢加入了一个模糊的效果,配合后面的选中的时候图标颜色显示会形成一个类似于色彩过度的效果,并且将i元素上面设置的颜色显示出来



    .nav ul li span::before {
content: '';
position: absolute;
top: 10px;
left: 0;
width: 100%;
height: 100%;
background: orange;
border-radius: 50%;
filter: blur(40px);
opacity: 0;
transition: 0.5s;
transition-delay: 0s;
}
.nav ul li span::before {
opacity: 0.5;
transition-delay: 0.25s;
}
/* 这里将i元素设置的颜色显示出来 这两个样式块中都使用了 background: var(--clr); 属性,可以将背景颜色设置为clr 变量所表示的值。这种使用自定义变量的方式,可以在代码中统一定义颜色值,以便在需要时进行统一更改。*/
.nav ul li.active span {
background: var(--clr);
}

.nav ul li span::before {
background: var(--clr);
}


image.png


接下来设置背景圆



这里呢设置了背后的那个向下突兀的圆,其原理是通过位置的调整和颜色的与背景颜色的一致加上zindex的图册优先级的显示,构成了这么一个背景半圆形图



.indicator {
/* 这里进行了定位,并且设置了背景园的位置,同时将圆的背景颜色与背景颜色设为一致,会形成那种向下突兀的圆形,并且加入了动画 ps:这个过度的小圆弧我是真设置不好,凑合看吧,大佬们有能力的可以试试设置一下*/
position: absolute;
top: -35px;
width: 70.5px;
height: 70px;
background: #222327;
border-radius: 50%;
z-index: 1;
transition: 0.5s;
}
/* 设置左边半弧 */
.indicator::before {
content: '';
position: absolute;
top: 16px;
left: -34px;
width: 10px;
height: 5px;
background: transparent;
border-radius: 50%;
box-shadow: 20.5px 19px 0 4px #fff;
}
/* 设置右边半弧 */
.indicator::after {
content: '';
position: absolute;
top: 16px;
left: 54px;
width: 10px;
height: 5px;
background: transparent;
border-radius: 50%;
box-shadow: 20px 19px 0 4px #fff;
}

image.png


****动画设置,配合js形成点击的时候,active会移动到点击的目标身上



这里呢使用了nth-child选择器选中对应的i元素,注意,这里设置的平移效果是由clac函数计算而来,选中其中一个i元素,并且当且仅当具有active类之后的所有兄弟中的.indicator类元素,有一个指示器元素(.indicator)。指示器的位置会根据活动项目(具有active类的<li>元素)的位置进行调整。
根据活动项目的位置设置指示器的水平平移距离,实现一个在导航菜单中显示当前选中项目的效果。指示器的位置和平移距离是根据活动项目的索引和固定的长度单位(70px)进行计算的。



/*/* nth-child()选中低某个i元素,然后配合js完成背景圆的移动 
在CSS中,calc() 是一个用于执行计算的函数。它允许在CSS属性值中使用数学表达式。
这种计算函数通常用于允许动态计算和调整元素的尺寸、间距或位置。在 calc() 函数中,可以使用不同的运算符(如加号 +、减号 -、乘号 *、除号 /)来结合数值和单位进行计算。
它可以包含其他长度单位(如像素 px、百分比 % 等),并且可以与其他CSS属性值和变量一起使用。

当一个 `<li>` 元素具有 `active` 类时,对应的 `.indicator` 元素会相对于活动项目的位置水平平移一个特定的距离。每个 `.indicator` 元素的平移距离相对于其前面的活动项目索引和一个固定的长度单位(`70px`)计算得出。

*/
*/

.nav li:nth-child(1).active~.indicator{
transform: translateX(calc(70px*0));
}

.nav li:nth-child(2).active~.indicator {
transform: translateX(calc(70px*1));
}

.nav li:nth-child(3).active~.indicator {
transform: translateX(calc(70px*2));
}

.nav li:nth-child(4).active~.indicator {
transform: translateX(calc(70px*3));
}

.nav li:nth-child(5).active~.indicator {
transform: translateX(calc(70px*4));
}


这里配合js代码,通过foreach为点击的li或者为所有的li进行添加或者移入active样式


<script>
//通过 `lis.forEach(li => li.addEventListener('click', function () {...}))` 遍历 `lis` 数组中的每个元素,并为每个元素都添加一个 ‘click’ 事件监听器。
//在每次点击事件中,使用 `lis.forEach(item => {...})` 遍历 `lis` 数组中的每个元素,将它们的 `active` 类都移除,然后在当前被点击的元素上添加 `active` 类,
const lis = document.querySelectorAll('.nav li')
lis.forEach(li => li.addEventListener('click', function () {
lis.forEach(item => {
item.classList.remove('active');
this.classList.add('active');
})
}))
</script>

image.png


效果展示


ezgif.com-video-to-gif.gif


总结


这里配合js使用的动画是值得我学习的,通过js点击赋予不同的liactive样式,又根据active所在的li元素经过计算对.indicator元素进行平移,使之完成这个动画效果


已上传至gitee
gitee.com/wu-canhua/b…


作者:如意呀
来源:juejin.cn/post/7262334378759405605
收起阅读 »

2023年终总结:不想内卷要如何破局

年目标 算法刷了一丢丢,争取上班每天至少刷一题 往全栈方向转,Nest学习情况 待填坑: nginx复习(是的几年没用忘光了) docker k8s 理财计划 我是从大学就有存钱意识了,大二定定存基金,每个月固定存几百进去,虽然不多,但是积少成多,到了...
继续阅读 »

年目标


算法刷了一丢丢,争取上班每天至少刷一题


image.png


往全栈方向转,Nest学习情况


待填坑:



  • nginx复习(是的几年没用忘光了)

  • docker

  • k8s
    image.png


理财计划


我是从大学就有存钱意识了,大二定定存基金,每个月固定存几百进去,虽然不多,但是积少成多,到了大三暑假旅游时,存钱数量已经是同学里最多的(不到1万但是够旅游一次了)


《小狗钱钱》/《富爸爸穷爸爸》对我来说借鉴意义不大,对我影响比较大的理财书籍是《工作前5年,决定你一生的财富》,我工作5年存的钱也比作者多一点,由于股市即时抽身(疫情期间),赚了一丢丢


之前有个同事还会制定每年的理财收支表,总结收入存款情况,被卷到了


休闲


上半年感觉一直在上班,心态很差,4月周末去了一趟潮州


公司团建


sunset.jpg
luying.jpg


tuanjian2.jpg


6月内蒙古,他们那边都不吃蔬菜的,吃了一周的牛羊肉都不想吃肉了


neimeng.jpg


7月蔡依林演唱会,票没抢到,无奈去了票贩子那里高价收了看台票


jolin.jpg


8月张韶涵演唱会,很顺利抢到了内场票,就是心脏振动的有点不舒服


anglela.jpg


me.jpg


9月emo了去了苏杭,风景很秀丽


hanzghou1.jpg


suzhou2.jpg


suzhou1.jpg
大闸蟹个人感觉不好吃


wuzhen2.jpg


10月回家了,如果可以想一直待在家里


hometown.jpg


11月去了一趟腾冲,我果然是精神云南人,想每年去一次云南


烧肉米线、铜瓢牛肉、过桥米线、稀豆粉我都好喜欢,可惜过了吃野生菌的季节


tengchong1.jpg
银杏村


tengchong2.jpg


tengchong3.jpg


英雄联盟手游这个赛季卡在大师上不去,老是遇到抢位置的骂人的,搞我心态


接下来还有蔡健雅演唱会和邓紫棋演唱会


书籍


没有特意约束一年要看多少书,《长安的荔枝》/《撒哈拉的故事》/《小家越装越大》都不错


要存钱准备房子装修了


后疫情时代


疫情过后,降薪裁员,我们组也减员了,更少的工资更多的工作。很多同行应该也经历过节假日加班。上半年经常会因为工作的事情失眠,后面心态也放平了,不能把工作带到生活中,休假的时候就好好享受,上班再处理工作的事情。(对于心理健康大有裨益)


内卷是资源少了毫无意义的恶性竞争,现在我们这行就有这个情况,目前工作上内卷没有什么前途,总之我想试试往远程方向,躺平是不可能躺平的


豆瓣上有fire小组,基本上都是年龄35+/40+的人,20多岁fire的还是少数,不考虑结婚生子买房,其实人过完这一生不需要花太多


目前前端已经是老手了,怎么面对35岁危机,还没有好的思绪


作者:lyllovelemon
来源:juejin.cn/post/7308624619163009075
收起阅读 »

还在手打console.log?快来试试这个vscode插件 Quickly Log!!

背景 作为一枚前端开发人员,尤其是在写业务代码的时候,避免不了需要经常在控制台打印变量来查看数据(反正我是这样哈哈哈哈哈),那么就需要频繁的来写console.log(),然后在里面再输入自己想要查看的变量名。 思考 既然我们需要频繁的来进行这个操作,那么我们...
继续阅读 »

背景


作为一枚前端开发人员,尤其是在写业务代码的时候,避免不了需要经常在控制台打印变量来查看数据(反正我是这样哈哈哈哈哈),那么就需要频繁的来写console.log(),然后在里面再输入自己想要查看的变量名。


思考


既然我们需要频繁的来进行这个操作,那么我们是不是可以把它像代码片段一样来保存下来,然后配置一个激活他的快捷键来进行使用


在左下角这里选择用户代码片段


image.png


然后选择想要使代码片段生效的文件类型,比如我这里选择的tsx



选择了对应的文件类型,对应的代码片段只会在这个类型的文件里生效,想要在其他类型的文件里也使用同样的代码片段需要去对应的类型文件中复制一份



image.png


把对应的代码片段写入


"Print to console": {
// 说明
"description": "Log output to console",
// 快捷键
"prefix": "cl",
// 输出内容
"body": ["console.log($1)"]
},

这样我们就配置好了一个简易的代码片段,使用的时候只需要敲出来 ’cl‘就会出现我们的代码提示


image.png


这样我们就解决了这个问题(我自己也是使用这个方法很久)


更进一步


目前我们通过代码片段已经解决了这个问题,但是还是会有一些不方便的地方



  • 我们只能在写好代码片段的类型文件中使用,我们现在使用 .tsx,突然要写 .vue 了使用的时候发现没有生效,就又要再去配置一次

  • 如果我们目前的 console.log 比较多,那么控制台上就会看到输出了一堆的变量,根本搞不清哪个打印是对应的哪个变量

  • 有时候会遗忘删掉 console.log 语句 (也可以通过配置husky,在commit的时候进行校验解决)


为了解决这些问题,我们更进一步,来通过写一个vscode插件解决


Quickly Log


这个插件最开始只是对vscode插件开发的好奇,加上自己确实有这方面的需求才开始编写的。编写成插件就可以有效的解决了需要重复配置代码片段的问题。


这里介绍下插件的功能,不对代码具体介绍,感兴趣的可以去github上看下代码 github.com/Richard-Zha…


功能


提示配置


只需要将光标移动到变量附近,然后使用快捷键 Cmd + Shift + L,就会在下一行输出语句


image.png


这里也支持携带上变量所在的行号以及文件名


image.png


当然这些都是可以配置的,可以根据自己的喜好来配置输出的提示内容


image.png


如果是简洁党也可全都取消勾选,效果就和直接使用上面提到的代码片段一样,但是会支持自动将变量放入console.log()的括号内


一键clear


执行 Cmd + Shift + K 就会将当前页面匹配到的console.log语句自动删除


一键切换注释


执行 Cmd + Shift + J 就会将当前页面匹配到的console.log语句前面自动打上注释,再执行就会取消注释


快捷键都是可以更改的 vscode左下角的设置icon点开 点击键盘快捷方式 输入 Quickly Log进行更改


以上就是目前插件支持的功能了,欢迎大家去Vscode下载使用


image.png


TODO


目前有些场景的打印是有问题的


比如下面这样的换行场景,我们希望在光标放在a,b,c这里的时候,会在第21行这里插入console.log语句,但是目前只会在光标的下一行插入,还需要手动移动到下面


image.png


image.png


之前有试过通过判断是否在 {} 内来输入到整个语句之后,但是情况不太理想,后续再考虑解决


作者:Richard_Zhang
来源:juejin.cn/post/7306806944046678052
收起阅读 »

Android 14 适配的那些事情

大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发简介距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持...
继续阅读 »
  • 大家好,我叫 Jack Darren,目前主要负责国内游戏发行 Android SDK 开发

简介

  • 距离 Android 14 发布已经有一段时间了,趁着这次机会,了解和熟悉了 Android 14 更新的内容,现在来和大家分享一下,大家喜欢的话可以点个赞多多支持一下,文章的内容按照适配内容的重要程度进行排序。

targetSdk 版本要求

  • 在 Android 14 上面,新增了一个要求,要求新安装的应用的 targetSdkVersion 需要大于等于 23(即 Android 6.0 及以上),如果小于这个值将无法在 Android 14 的设备上面安装,此时大家心里可能有疑惑了,谷歌为什么要求那么做呢?我们来看看谷歌的原话是什么
恶意软件通常会以较旧的 API 级别为目标平台,
以绕过在较新版本 Android 中引入的安全和隐私保护机制。
例如,有些恶意软件应用使用 targetSdkVersion 22
以避免受到 Android 6.0 Marshmallow(API 级别 23)在 2015 年引入的运行时权限模型的约束。
这项 Android 14 变更使恶意软件更难以规避安全和隐私权方面的改进限制。
  • 从上面这段话不难看出来谷歌的用意,其实为了保障用户的手机安全,如果用户安装应用的 targetSdkVersion 版本过低,有一些恶意软件会利用高系统会兼容旧软件这一特性(漏洞),故意绕过系统的安全检查,从而会导致 Android 高版本上面一些安全特性无法生效,没有了系统的管束,这些恶意软件可能就会肆意乱来。
  • 另外你如果想在 Android 14 系统上面,仍然要安装 targetSdkVersion 小于 23 的应用,可以通过以下 adb 命令来安装 apk,这样就能绕过系统的安装限制。
adb install --bypass-low-target-sdk-block xxx.apk

前台服务类型要求

  • 如果你的应用 targetSdkVersion 升级到了 34(即 Android 14),并且在 Service 中调用了 startForeground 方法,那么就需要进行适配了,否则系统会抛出 MissingForegroundServiceTypeException 异常,这是因为在 Android 14 上面,要求应用在开启前台服务的时候,需要注明这个前台服务的用途,谷歌给我们列举了以下几种用途:
用途说明清单文件权限要求运行时要求
摄像头继续在后台访问相机,例如支持多任务的视频聊天应用FOREGROUND_SERVICE_CAMERA请求 CAMERA 运行时权限
连接的设备与需要蓝牙、NFC、IR、USB 或网络连接的外部设备进行互动FOREGROUND_SERVICE_CONNECTED_DEVICE必须至少满足以下其中一个条件:

在清单中至少声明以下其中一项权限:

CHANGE_NETWORK_STATE
CHANGE_WIFI_STATE
CHANGE_WIFI_MULTICAST_STATE
NFC
TRANSMIT_IR
至少请求以下其中一项运行时权限:

BLUETOOTH_CONNECT
BLUETOOTH_ADVERTISE
BLUETOOTH_SCAN
UWB_RANGING
调用 UsbManager.requestPermission()

数据同步数据传输操作,例如:

数据上传或下载
备份和恢复操作
导入或导出操作
获取数据
本地文件处理
通过网络在设备和云端之间传输数据
FOREGROUND_SERVICE_DATA_SYNC
健康为健身类别的应用(例如锻炼追踪器)提供支持的所有长时间运行的用例FOREGROUND_SERVICE_HEALTH必须至少满足以下其中一个条件:

在清单中声明 HIGH_SAMPLING_RATE_SENSORS 权限。

至少请求以下其中一项运行时权限:

BODY_SENSORS
ACTIVITY_RECOGNITION
位置需要位置信息使用权的长时间运行的用例,
例如导航和位置信息分享
FOREGROUND_SERVICE_LOCATION至少请求以下其中一项运行时权限:

ACCESS_COARSE_LOCATION
ACCESS_FINE_LOCATION
媒体在后台继续播放音频或视频。
在 Android TV 上支持数字视频录制 (DVR) 功能。
FOREGROUND_SERVICE_MEDIA_PLAYBACK
媒体投影使用 MediaProjection API 将内容投影到非主要显示屏或外部设备。这些内容不必全都为媒体内容。不包括 Cast SDKFOREGROUND_SERVICE_MEDIA_PROJECTION调用 createScreenCaptureIntent() 方法。 无
麦克风在后台继续捕获麦克风内容,例如录音器或通信应用FOREGROUND_SERVICE_MICROPHONE请求 RECORD_AUDIO 运行时权限
打电话使用 ConnectionService API 继续当前通话FOREGROUND_SERVICE_PHONE_CALL在清单文件中声明 MANAGE_OWN_CALLS 权限。
消息服务将短信从一台设备转移到另一台设备。在用户切换设备时,帮助确保用户消息任务的连续性FOREGROUND_SERVICE_REMOTE_MESSAGING
短期服务快速完成不可中断或推迟的关键工作。

这种类型有一些独特的特征:

只能持续运行一小段时间(大约 3 分钟)。
不支持粘性前台服务。
无法启动其他前台服务。
不需要类型专用权限,不过它仍需要 FOREGROUND_SERVICE 权限。
正在运行的前台服务不能更改为 shortService 类型或从该类型更改为其他类型。
特殊用途涵盖其他前台服务类型未涵盖的所有有效前台服务用例。

除了声明 FOREGROUND_SERVICE_TYPE_SPECIAL_USE 前台服务类型之外,开发者还应在清单中声明用例。为此,他们会在 `` 元素内指定  元素。当您在 Google Play 管理中心内提交应用时,我们会审核这些值和相应的用例。
FOREGROUND_SERVICE_SPECIAL_USE
系统豁免为系统应用和特定系统集成预留,
使其能继续使用前台服务。

如需使用此类型,应用必须至少满足以下条件之一:

设备处于演示模式状态
应用是设备所有者
应用是性能分析器所有者
属于具有 ROLE_EMERGENCY 角色的安全应用
属于设备管理应用
否则,声明此类型会导致系统抛出 ForegroundServiceTypeNotAllowedException
FOREGROUND_SERVICE_SYSTEM_EXEMPTED
  • 介绍完这几种前台服务类型,接下来介绍如何适配它,适配前台服务类型的特性方式具体有两种方式,一种是注册清单属性,另外一种是代码动态注册
<service
android:name=".XxxService"
android:foregroundServiceType="dataSync"
android:exported="false">

service>
startForeground(xxx, xxx, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
  • 另外附上前台服务类型对应的适配属性
用途清单文件属性值Java 常量值
摄像头cameraServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
连接的设备connectedDeviceServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
数据同步dataSyncServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
健康healthServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
位置locationServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
媒体mediaPlaybackServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
媒体投影mediaProjectionServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
麦克风microphoneServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
打电话phoneCallServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
消息服务remoteMessagingServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
短期服务shortServiceServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
特殊用途specialUseServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
系统豁免systemExemptedServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED

图片和视频的部分访问权限

  • 谷歌在 API 33(Android 13)上面引入了 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 这两个权限,目前针对这两个权限在 Android 14 上面有新的变动,具体的变动点就是新增了 READ_MEDIA_VISUAL_USER_SELECTED 权限,那么这个权限的作用是什么呢?我们都知道 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 是申请图片和视频权限的,但是这样会有一个问题,当第三方应用申请到权限后,就拥有了手机相册中所有照片和视频的访问权限,这是十分危险的,也是非常不可控的,因为用户也无法知道第三方应用会干什么,所以谷歌在 API 34(Android 14)引入了这个权限,这样用户拥有了更多的选择,可以将相册中所有的图片和视频授予给第三方应用,也可以将部分的图片和视频给第三方应用。
  • 讲完了这个特性的来龙去脉,那么接下来讲讲这个权限如何适配,如果你的应用申请了 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,并且 targetSdkVersion 大于等于 33(Android 13),那么需要在申请权限时携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限方能正常申请,如果不携带上 READ_MEDIA_VISUAL_USER_SELECTED 权限就申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,会弹出权限询问对话框,但是如果用户是选择全部授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已授予的状态,如果用户是选择部分授予,那么 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限状态是已拒绝的状态,假设此时有携带了 READ_MEDIA_VISUAL_USER_SELECTED 权限的情况下,那么 READ_MEDIA_VISUAL_USER_SELECTED 权限是已授予的状态。
  • 看到这里,脑洞大的同学可能有想法了,那我不申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限,我就只申请 READ_MEDIA_VISUAL_USER_SELECTED 权限行不行啊?答案也是不行的,我替大家试验过了,这个权限申请会在不会询问用户的情况下,被系统直接拒绝掉。
  • 另外需要的一点是 READ_MEDIA_VISUAL_USER_SELECTED 属于危险权限,除了在运行时动态申请外,还需要在清单文件中进行注册。

registerReceiver 需要指定导出行为

  • 谷歌在 Android 12 (API 31)新增了四大组件需要指定 android:exported 属性的特性,这次在 Android 13 上面做了一些变动,因为谷歌之前只考虑到静态注册四大组件的情况,但是遗漏了一种情况,BroadcastReceiver 不仅可以静态注册,还可以动态注册,动态注册的广播不需要额外在 AndroidManifest.xml 中再进行静态注册,所以这次谷歌将这个规则漏洞补上了,并且要求开发者在动态注册广播的时候,能够指定 BroadcastReceiver 是否能支持导出,由此来保护应用免受安全漏洞的影响。
  • 到此,大家心中可能有一个疑惑,这里的支持导出是什么意思?有产生什么作用?可以先看一下谷歌官方的原话
为了帮助提高运行时接收器的安全性,Android 13 允许您指定您应用中的特定广播接收器是否应被导出以及是否对设备上的其他应用可见。
如果导出广播接收器,其他应用将可以向您的应用发送不受保护的广播。
此导出配置在以 Android 13 或更高版本为目标平台的应用中可用,有助于防止一个主要的应用漏洞来源。

在以前的 Android 版本中,设备上的任何应用都可以向动态注册的接收器发送不受保护的广播,除非该接收器受签名权限的保护。
  • 谷歌的解释很明了,如果广播支持导出,那么其他应用可以通过发送这个广播触发我们应用的逻辑,这可能会发生程序安全漏洞的问题。
  • 那么该如何适配这一特性呢?谷歌官方提供了一个 registerReceiver(BroadcastReceiver receiver, IntentFilter filter, int flags) API,flags 参数传入 Context.RECEIVER_EXPORTED(支持导出) 或 Context.RECEIVER_NOT_EXPORTED(不支持导出),具体的代码适配代码如下:
String action = "xxxxxx";
IntentFilter filter = new IntentFilter(action);
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
context.registerReceiver(new LocaleChangeReceiver(), filter, Context.RECEIVER_EXPORTED);
} else {
context.registerReceiver(new LocaleChangeReceiver(), filter);
}
  • 还有一种情况,不需要指定 flag 参数,就是当要注册的广播 action 隶属系统的 action 时候,这个时候可以不需要指定导出行为。

更安全的动态代码加载

  • 如果我们应用有动态加载代码的需求,并且此时 targetSdk 升级到了 API 34(即 Android 14),那么需要注意一个点,动态加载的文件(Jar、Dex、Apk 格式)需要设置成可读的,具体案例的代码如下:
File jar = new File("xxxx.jar");
try (FileOutputStream os = new FileOutputStream(jar)) {
jar.setReadOnly();
} catch (IOException e) { ... }
PathClassLoader cl = new PathClassLoader(jar, parentClassLoader);
  • 至于谷歌这样做的原因,我觉得十分简单,是为了程序的安全,防止有人抢先在动态加载之前先把动态文件替换了,那么会导致执行到一些恶意的代码,间接导致应用被入侵或者篡改。
  • 另外需要注意的一个点的是,如果你的应用 targetSdk 大于等于 API 34(即 Android 14),如果不去适配这一特性,那么运行在 Android 14 的手机上面系统会抛出异常。

屏幕截图检测

  • Android 14 新增引入了屏幕截图检测的 API,方便开发者更好地检测到用户的操作,具体的使用案例如下:

    1. 在清单文件中静态注册权限
    <uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
    1. 创建监听器对象
final Activity.ScreenCaptureCallback screenCaptureCallback = new Activity.ScreenCaptureCallback() {

@Override
public void onScreenCaptured() {
// 监听到截图了
}
};
  1. 在合适的时机注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStart() {
super.onStart();
registerScreenCaptureCallback(executor, screenCaptureCallback);
}
}
  1. 在合适的时机取消注册监听
public final class XxxActivity extends Activity {

@Override
protected void onStop() {
super.onStop();
unregisterScreenCaptureCallback(screenCaptureCallback);
}
}
  • 需要注意的是,如果使用的是 adb 进行的截图,并不会触发 onScreenCaptured 监听方法。
  • 如果不想你的应用能被系统截图,可以考虑给当前的 Window 窗口加上 WindowManager.LayoutParams.FLAG_SECURE 标记位。
  • 最后表达一下我对这个 API 看法,这个 API 设计得不是很好,比如应用想知道用户是否截图了,应用可能需要知道的是,截图文件的存放路径,但是 onScreenCaptured 是一个空参函数,也就意味着没有携带任何参数,如果要实现获取截图文件存放路径的需求,可能还需要沿用之前的老方式,即使用 ContentObserver 监听媒体数据库的变化,然后从几个维度(文件时间维度、文件路径维度、图片尺寸维度)判断新增的图片是否为用户的截图,这种实现的方式相对是比较麻烦的,但是也无发现更好的实现方式。
  • 完结,撒花 ✿✿ヽ(°▽°)ノ✿


    作者:37手游移动客户端团队
    来源:juejin.cn/post/7308434314777772042
    收起阅读 »