车机系统与Android的关系
前言:搞懂 Android 系统和汽车到底有什么关系。
一、基本概念
1、Android Auto
1)是什么
- Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;
- 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;
2)功能
- Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;
- 满足了很多人在开车时会使用手机的需求;
2、Google Assistant
- Google 将 GoofleAssistant 集成到 AndroidAuto 中;
- 交互方式有键盘、触摸、语音等;
- 对于汽车来说,语音无疑是比触摸更好的交互方式;
- 在驾驶环境中,语音交换存在的优势
- 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;
- 有需要多次触摸的交互时,可能只需要一条语音就可以完成;
- 语音交互不存在入口的层次嵌套,数据更加扁平;
- 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;
3、Android Automotive
1、Android Auto 和 Android Automotive 的区别
- Android Auto 是以手机为中心的
- 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;
- 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;
- Android Automotive
- 如果将系统直接内置于汽车中,会大大提升用户体验;
- Android Automotive 就是面向这个方向进行设计的;
- 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;
加两张中控和仪表的图片
4、App
1)App 的开发
- Android Auto 目前仅支持两类第三方应用
- 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;
- 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;
2)App 的设计
- Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;
- 基本指导原则(车机交互系统的借鉴)
- Android Auto 上的互动步调必须由驾驶员控制;
- 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;
- 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;
- 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;
- Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;
- 通过触摸来进行分页应用用来作为滑动翻页的补充;
- 有节制地使用动画来描述两个状态间的变化;
二、源码和架构
1、Android Automative的整体架构
- Android Automative 的源码包含在 AOSP 中;
- Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;
- Car App:包括 OEM 和第三方开发的 App;
- OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;
- Car API:提供给汽车 App 特有的接口;
- Car Service:系统中与车相关的服务;
- Vehicle Network Service:汽车的网络服务;
- Vehicle HAL:汽车的硬件抽象层描述;
- Car App:包括 OEM 和第三方开发的 App;
1)Car App
- /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;
- 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;
- App的源码都位于 /platform/packages/services/Car/ 目录下
# Automotive specific packages
PRODUCT_PACKAGES += \
vehicle_monitor_service \
CarService \
CarTrustAgentService \
CarDialerApp \
CarRadioApp \
OverviewApp \
CarLensPickerApp \
LocalMediaPlayer \
CarMediaApp \
CarMessengerApp \
CarHvacApp \
CarMapsPlaceholder \
CarLatinIME \
CarUsbHandler \
android.car \
libvehiclemonitor-native \
2)Car API
- 开发汽车专有的App自然需要专有的API;
- 这些API对于其他平台(例如手机和平板)通常是没有意义的;
- 所以这些API没有包含在Android Framework SDK中;
- 下图列出了所有的 Car API;
- android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。
- cabin:座舱相关API。
- hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)
- property:属性相关API。
- radio:收音机相关API。
- pm:应用包相关API。
- render:渲染相关API。
- menu:车辆应用菜单相关API。
- annotation:包含了两个注解。
- app
- cluster:仪表盘相关API。
- content
- diagnostic:包含与汽车诊断相关的API。
- hardware:车辆硬件相关API。
- input:输入相关API。
- media:多媒体相关API。
- navigation:导航相关API。
- settings:设置相关API。
- vms:汽车监测相关API。
3)Car Service
- Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;
public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier) {
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());
4)Car Tool
a、VMS
- VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;
- 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置
service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical
on boot
start vms
- 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;
来源:juejin.cn/post/7356981730765291558
Flutter 用什么架构方式才合理?
前言
刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。
Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。
不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。
那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?
我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。
使用部分
结合GetX, 使用方式如下:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';
class CustomController extends MvcController {
var count = 0.obs;
void addCount() {
count.value++;
}
}
class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}
简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。
至于 MVC 中的 Model 去哪里了?你猜猜😘。
代码封装部分
代码封装也很简洁,封装的 controller 代码如下
import 'package:flutter/material.dart';
class MvcController with ChangeNotifier {
late BuildContext context;
@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}
@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}
void onDispose() {}
}
封装的 view 代码如下
import 'package:flutter/material.dart';
import 'controller.dart';
typedef MvcBuilder<T> = Widget Function(T controller);
class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;
const MvcView({
super.key,
required this.controller,
this.builder,
});
Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}
@override
State<MvcView> createState() => _MvcViewState();
}
class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}
void onChanged() {
if (context.mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}
@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}
@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}
结语
MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。
附上的作品文件结构截图,亲喷哈~
感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?
来源:juejin.cn/post/7340472228927914024
可视化大屏开发,知道这三个适配方案,效率翻倍!
哈喽,大家好 我是
xy
👨🏻💻。今天和大家来聊一聊大屏可视化
适配过程中的痛点以及怎么去解决
这些痛点!!!
前言
开发过大屏可视化
应用的前端工程师们通常会有这样的共识:
在可视化开发过程中,最具有挑战性的并非各种图表
的配置与效果展示
,而是如何确保大屏
在不同尺寸
的屏幕上都能实现良好的适配。
原始解决方案
起初比较流行的三大
解决方式:
rem 方案
- 动态设置
HTML
根字体大小和body
字体大小,配合百分比
或者vw/vh
实现容器宽高
、字体大小
、位移
的动态调整
vw/vh 方案
- 将
像素值
(px)按比例
换算为视口宽度
(vw)和视口高度
(vh),能够实时计算图表尺寸、字体大小等
scale 方案
- 根据
宽高比例
进行动态缩放,代码简洁,几行代码即可解决,但是遇到一些地图
或者Canvas
中的点击事件,可能会存在错位问题,需要做针对性的调整适配
以上三种方式,都能够实现大屏的基本适配!
但是在开发过程中需要对每个字体
和容器
去做相应的计算调整,相对来说较为繁琐
,而且在团队协作
过程中也容易出现问题。
那么有没有一种方式,只需要简单的一些配置
,就能完全搞定大屏在不同尺寸的屏幕上都能实现良好的适配
?
以下给大家推荐三个方案
,只需要简单的几行代码配置,可以完全解决大屏开发中的适配问题,让你效率翻倍!!!
autofit.js
autofit.js
基于比例缩放
原理,通过动态调整容器的宽度和高度来实现全屏填充,避免元素的挤压或拉伸。
autofit.js 提供了一种简单而有效的方法来实现网页的自适应设计,尤其适合需要在不同分辨率
和屏幕尺寸
下保持布局一致性的应用场景。
安装:
npm i autofit.js
配置:
import autofit from 'autofit.js';
onMounted(() => {
autofit.init({
el: '#page',
dw: 375,
dh: 667
})
})
* - 传入对象,对象中的属性如下:
* - el(可选):渲染的元素,默认是 "body"
* - dw(可选):设计稿的宽度,默认是 1920
* - dh(可选):设计稿的高度,默认是 1080
* - resize(可选):是否监听resize事件,默认是 true
* - ignore(可选):忽略缩放的元素(该元素将反向缩放),参数见readme.md
* - transition(可选):过渡时间,默认是 0
* - delay(可选):延迟,默认是 0
源码地址
Github 地址:
https://github.com/995231030/autofit.js
v-scale-screen
大屏自适应容器组件
,可用于大屏项目开发,实现屏幕自适应,可根据宽度自适应
,高度自适应
,和宽高等比例自适应
,全屏自适应
(会存在拉伸问题),如果是 React
开发者,可以使用 r-scale-screen
。
安装:
npm install v-scale-screen
# or
yarn add v-scale-screen
配置:
<template>
<v-scale-screen width="1920" height="1080">
<div>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
</div>
</v-scale-screen>
</template>
<script>
import { defineComponent } from 'vue'
import VScaleScreen from 'v-scale-screen'
export default defineComponent({
name: 'Test',
components: {
VScaleScreen
}
})
</script>
源码地址
github 地址:
https://github.com/Alfred-Skyblue/v-scale-screen
FitScreen
一种基于缩放
的大屏自适应解决方案的基本方法,一切都是基于设计草图的像素尺寸,通过缩放
进行适配
,一切都变得如此简单。
支持 vue2
、vue3
以及 react
,可以适用于任何框架,只要一点点代码。
安装:
npm install @fit-screen/vue
# or
yarn add @fit-screen/vue
# or
pnpm install @fit-screen/vue
配置:
<script setup>
import FitScreen from '@fit-screen/vue'
</script>
<template>
<FitScreen :width="1920" :height="1080" mode="fit">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</FitScreen>
</template>
源码地址
github 地址:
https://github.com/jp-liu/fit-screen
最后,如果大家有更好的适配方案
,欢迎在评论区留言一起学习交流!👏
最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者
回复加群,一起学习前端技能 公众号内包含很多实战
精选资源教程,欢迎关注
来源:juejin.cn/post/7386514632725872674
转转的Flutter实践之路
前言
跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。
从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。
经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。
回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。
可行性验证
其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。
目标
我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:
- 开发效率
- UI一致性
- 性能体验
- 学习成本
- 发展趋势
由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。
方案
我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。
测试目标
当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。
我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。
项目架构
由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。
关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。
构建流程
为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。
结论
经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:
在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。
在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。
在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。
Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。
在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。
整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。
基建一期建设
基建一期内容主要包括以下几个方面:
- 工程架构
- 开发框架
- 脚本工具
- 自动化构建
在基建一期完成后,我们的目标是要达到:
- 基础能力足够支撑普通业务开发
- 开发效率接近原生开发
- 开发过程要基本顺畅
工程架构
工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。
原生工程与Flutter工程的关系
我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。
而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。
所以原生工程与 Flutter 工程之间的关系如下图所示:
Flutter工程之间的关系
根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:
- 壳工程
- 业务层
- 公共层
- 容器层
容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。
公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。
业务层包含用户信息、商品、发布等业务组件。
壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。
其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:
开发框架
开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。
基础能力
开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。
状态管理
相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。
另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。
基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。
如下图,两个页面的代码结构基本一致:
页面栈管理
在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。
脚本工具
为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:
- Flutter开发环境安装
- Flutter版本管理
- 创建模版工程(主工程、组件工程)
- 创建模版页面(常规页面、列表页、瀑布流页面)
- 创建页面模块
- 组件工程发布
- 构建Flutter产物
- 脚本自更新
如图:
自动化构建
客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。
小范围试验
在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。
我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:
这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。
经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:
基础能力
目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。
工作流
整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。
开发效率
我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。
性能体验
线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。
包体积
在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:
- Android增加6.1M
- iOS增加14M
试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。
基建二期建设
基建二期的内容主要包含以下工作:
- 配合工程效率组完成 Beetle 对 Flutter 项目的支持
- 组织客户端内部进行 Flutter 技术培训
Beetle支持Flutter
为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:
- Native主工程(又分为 Android 和 iOS)
- Native组件工程(又分为 Android 和 iOS)
- Flutter主工程
- Flutter组件工程(即 Flutter 插件工程)
举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。
Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。
Flutter技术培训
为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:
同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。
大范围推广
在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。
我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。
部分页面如下图所示:
探索前端生态
在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。
技术调研
当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。
Kraken
Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。
Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。
Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。
MXFlutter
MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。
MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。
Flutter For Web
Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。
- 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。
- 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。
HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。
结论
我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:
- 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web
- 渲染性能:Kraken > MXFlutter > Flutter For Web
- 包体积增量:Flutter For Web < Kraken < MXFlutter
- 开发生态:Kraken ≈ MXFlutter > Flutter For Web
- 学习成本:Flutter For Web < Kraken ≈ MXFlutter
最终选择了 Kraken 作为我们的首选方案。
上线验证
为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:
- 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件
- 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力
- 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力
- 制定 Kraken 容器与 Web 容器的降级机制
- 兼容 HTML 加载,保持跟 Web 容器一致的加载方式
- 添加监控埋点,量化指标,指导后续优化方向
- 选择一个简单 Web 页并协助前端同学适配
上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。
通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。
再次验证
由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:
最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。
存在的问题包括但不限于下面列举的一些:
- 表现不一致问题
- CSS 定位、布局表现与浏览器表现不一致
- 部分 API 表现与浏览器不一致(getBoundingClientRect等)
- iOS,Android系统表现不一致
- 重大 Bug
- 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染
- 滑动监听计算导致 APP 崩溃
- 调试成本高
- 不支持 vue-router,单项目单路由
- 不支持热更新,npm run build 预览
- 不支持 sourceMap,无法定位源代码
- 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......
- 页面白屏,假死
- 安全性问题
- 无浏览器中的“同源策略”限制
- 兼容性
- npm 包不兼容等
通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。
结尾
目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~
来源:juejin.cn/post/7304831120709697588
使用 uni-app 开发 APP 并上架 IOS 全过程
教你用 uni-app 开发 APP 上架 IOS 和 Android
介绍
本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、打包发布到应用商店配置的每一步骤,希望我的经验分享能为您提供实用的指导和帮助,让您在开发之旅中少走弯路,顺利实现自己的应用开发目标。
环境配置
IOS 环境配置
注册开发者账号
如果没有开发者账号需要注册苹果开发者账号,并且加入 “iOS Developer Program”,如果是公司项目那么可以将个人账号邀请到公司的项目中。
获取开发证书和配置文件
登录Apple Developer找到创建证书入口
申请证书的流程可以参考Dcloud官方的教程,申请ios证书教程
开发证书和发布证书都申请好应该是这个样子
创建App ID
创建一个App ID。App ID是iOS应用的唯一标识符,稍后你会在uni-app项目的配置文件中使用它。
配置测试机
第一步打开开发者后台点击Devices
第二步填写UDID
第三步重新生成开发证书并且勾选新增的测试机,建议一次性将所有需要测试的手机加入将来就不用一遍遍重复生成证书了
Android 环境配置
生成证书
Android平台签名证书(.keystore)生成指南: ask.dcloud.net.cn/article/357…
uni-app 项目构建配置
基础配置
版本号versionCode 前八位代表年月日,后两位代表打包次数
APP 图标设置
APP启动界面配置
App模块配置
注意这个页面用到什么就配置什么不然会影响APP审核
App隐私弹框配置
注意根据工业和信息化部关于开展APP侵害用户权益专项整治要求应用启动运行时需弹出隐私政策协议,说明应用采集用户数据,这里将详细介绍如何配置弹出“隐私协议和政策”提示框
详细内容可参考Uni官方文档
注意!androidPrivacy.json不要添加注释,会影响隐私政策提示框的显示!!!
在app启动界面配置勾选后会在项目中自动添加androidPrivacy.json文件,可以双击打开自定义配置以下内容:
{
"version" : "1",
"prompt" : "template",
"title" : "服务协议和隐私政策",
"message" : " 请你务必审慎阅读、充分理解“服务协议”和“隐私政策”各条款,包括但不限于:为了更好的向你提供服务,我们需要收集你的设备标识、操作日志等信息用于分析、优化应用性能。<br/> 你可阅读<a href="https://xxx.xxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxxx.xxxx.com/privacyPolicy.html">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "system|default",
"backToExit" : "false",
"second" : {
"title" : "确认提示",
"message" : " 进入应用前,你需先同意<a href="https://xxx.xxxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxx.xxxx.com/userPolicy.html">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"loadNativePlugins" : false,
"showAlways" : false
},
"styles" : {
"backgroundColor" : "#fff",
"borderRadius" : "5px",
"title" : {
"color" : "#fff"
},
"buttonAccept" : {
"color" : "#22B07D"
},
"buttonRefuse" : {
"color" : "#22B07D"
},
"buttonVisitor" : {
"color" : "#22B07D"
}
}
}
我的隐私协议页面是通过vite打包生成的多入口页面进行访问,因为只能填一个地址所以直接使用生产环境的例如:xxx.xxxx.com/userPolicy.…
构建打包
使用HBuilderX进行云打包
IOS打包
构建测试包
第一步 点击发行->原生app云打包
第二步配置打包变量
运行测试包
打开HbuildX->点击运行->运行到IOS App基座
选择设备->使用自定义基座运行
构建生产包
和构建测试包基本差不多,需要变更的就是ios证书的profile文件和密钥证书
构建成功后的包在dist目录下release文件夹中
上传生产包
上传IOS安装包的方式有很多我们选择通过transporter软件上传,下载transporter并上传安装包
确认无误后点击交付,点击交付后刷新后台,一般是5分钟左右就可以出现新的包了。
App store connect 配置
上传截屏
只要传6.5和5.5两种尺寸的就可,注意打包的时候千万不能勾选支持ipad选项,不然这里就会要求上传ipad截屏
填写app信息
配置发布方式
自动发布会在审核完成后直接发布,建议选手动发布
配置销售范围
配置隐私政策
配置完之后IOS就可以提交审核了,不管审核成功还是失败Apple都会发一封邮件通知你审核结果
安卓打包
构建测试包
构建的包在dist/debug目录下
运行测试包
如果需要运行的话,点击运行 -> 运行到Android App底座
构建生产包
构建后的包在dist目录下release文件夹中
构建好安卓包之后就可以在国内的各大手机厂商的应用商店上架了,由于安卓市场平台五花八门就不给大家一一列举了。
参考链接:
结语
本文介绍了使用uni-app开发并发布跨平台移动应用的完整流程,包括注册开发者账号、项目创建、打包发布以及应用商店配置,帮助开发者高效地将应用上架到iOS和Android平台。感谢您的阅读,希望本文能对您有所帮助。
来源:juejin.cn/post/7379958888909029395
我是如何实现网页颜色自适应的
前言
不知大家有没有留意过,当前大部分 App 或网页中,很少存在允许用户完全自定义要展示信息的颜色的功能。
例如在钉钉的自定义表情中,只允许用户从一组预设的配色中随机切换:
再比如笔记应用 Notion 虽然允许用户改变文本颜色,但也只允许在一组预设色值中选取:
原因无它,配色,不是一件容易事。
对于大众用户而言,没什么颜色理论知识,很可能挑出来的颜色在应用中很难看、看不清,这会极大的影响用户的使用体验(即使是用户自己造成的)。
因此大部分产品选择的做法是提供一组预先检验过的、不会对用户阅读造成困扰的颜色,放在应用中供用户挑选。
今天我来斗胆挑战一下这个业界难题。
在这篇文章中将会探讨两个具体问题:
- 如何让文本颜色自适应背景色
- 如何允许用户完全自定义主题色,同时保证可阅读性
文本颜色自适应背景色
在下面这张图中,文本的颜色默认都是黑色的,背景色设置了多个明暗不同的颜色。可以看到对于暗色的背景色,此时文本可阅读性特别差(不太明显,想看清楚会很累)。
如果能够自动根据背景色的明暗,决定使用白色还是黑色的文本,那便是实现了文本颜色的自适应了。
首先介绍下借助第三方库实现的方案。
第三方库实现
color
是 JavaScript 生态中在颜色处理方面最流行的库,它有诸多功能:颜色空间转换、颜色通道分解、获取对比度、颜色混合……
在文本颜色自适应这个场景中,最为方便的两个 API 是 isDark()
和 isLight()
,它们分别用来表示一个颜色是否为深色、是否为浅色。
实际应用:
import Color from 'color'
const BgColors = ['#f87171', '#fef08a', '#042f2e', ...]
export default function Page() {
return (
<main>
{BgColors.map((bg) => (
<div
style={{
background: bg,
// 根据背景是否为深色决定文本用白色还是黑色
color: Color(bg).isDark() ? 'white' : 'black'
}}
>
恍恍惚惚
</div>
))}
</main>
)
}
实际效果:
很 Nice ~
下面再来看下使用 CSS 的解决方案。
mix-blend-mode: difference
mix-blend-mode: difference
用于指定一个元素的颜色与背景色进行「差值」混合,可以使用如下公式表达:
# || 表示取绝对值
# 最终元素显示的颜色 = |元素原有的颜色 - 背景色|
result_color = | element_color - background_color |
例如:
- 文本颜色为白色
rgb(255 255 255)
背景色为蓝色rgb(0 0 255)
,最终文本颜色为黄色rgb(255 255 0)
- 文本颜色为黑色
rgb(0 0 0)
,此时无论背景色是什么颜色,最终文本的颜色一定和背景色完全相同,因为| 0 - x | = x
下面来看个实际的 demo,这里我们让文本颜色为 rbg(255, 255, 255)
,背景色动态调整:
可以看到这个方案会有如下问题:
- 当背景色为灰色时,文本的颜色和背景色很相似,对比度低,可阅读性差
- 当背景色为彩色时,混合出来的颜色也是彩色,而且颜色比较脏,不太美观
CSS 相对颜色
以 CSS 颜色函数 rgb()
举例,相对颜色的语法是通过 from
关键词扩展了该函数的能力:
color: rgb(from red r g b);
由于 red
的 RGB 是 (255, 0, 0)
,因此后面的 r g b
值分别为 255 0 0
。对于 r g b
还可以调用 calc()
或其他 CSS 函数进一步处理。
除了 rgb()
, rgba()
hsl()
hwb()
lch()
等等 CSS 颜色函数都是支持相对颜色语法的。
CSS 相对颜色语法带来的能力有调节亮度、调节饱和度、获取反转色、获取补充色……其中反转色就可以用于文本颜色自适应的场景中。
在上面 Demo 上,使用如下规则使文本的颜色为背景色的反转色:
color: rgb(from var(--bg) calc(255 - r) calc(255 - g) calc(255 - b));
实际效果:
可以看到虽然能一定程度上提升可阅读性,但是有些反转色奇奇怪怪的,和背景色搭配起来实在不美观,并不是特别推荐使用。
color-constract
color-constract()
可以说是最适合文本颜色自适应场景的 CSS 函数了,用法简单,效果好!
color: color-constract(var(--bg) vs color1, color2, ...);
它的作用即从 vs
右边的一堆色值中,挑选一个和 vs
左边的色值对比度最大的返回。
如 color-constract(#ccc vs #000, #fff)
,由于 #ccc
是个浅灰色,色值和 #000
对比度更大,因此这个函数会返回 #000
。
很美好。
但是!这个函数现在几乎不能用!
目前只有 Safari 中能使用,而且必须开启相关的实验性功能 Flag 。
完全允许用户自定义主题色
让文本的颜色根据背景色自适应,本质是在背景色(background)未知、前景色(foreground)有限的情况下,选择一个合适的前景色,使页面的可读性得到保障。
在上面的 Demo 中,背景色有淡紫色、淡黄色、深绿色、深棕色……前景色即文本的颜色只会有白色、黑色两种情况,依据背景色的明暗决定使用白色还是黑色。
相反的,我们讨论下前景色未知、背景色有限的情况。
看下这个实际应用场景:
在这款应用中,支持明、暗两种主题,明亮主题为左边的白色(#FFFFFF),暗夜主题为右边的深蓝色(#020617),这是两个背景色;标签主题色(即前景色)支持用户自己设定,图中以红色(#FF0000)为例。
通过截图可以看到,红色在这两种背景色上展示效果都还不错,主要是因为他们的对比度足够。
- 红色 vs 白色,对比度:3.998
- 红色 vs 深蓝色,对比度:5.045
要使页面的可读性得到保障,对比度至少要 > 3。
如果允许用户完全自定义前景色,就不可避免的出现用户选择的颜色和背景色的对比度 < 3,这时页面阅读起来会很费力,影响用户体验。
下面给出两种解决方案。
及时给出提示
允许用户完全自定义,意味着用户可以从色盘上选取任意颜色。当用户选取的颜色和背景色对比度 < 3 时,界面上可以给出适当提示,让用户自己决定用一个「难看的颜色」还是遵从应用的建议,选择一个对比度合理,在当前应用中可以和背景色合理搭配的颜色。
实际效果:
相关代码并没有太多难点,主要还是借助 color
库,通过 color1.contrast(color2)
获取两个颜色之间的对比度实现,这里就不放代码了。
自动计算对比度安全的颜色
另一种解决方案,就有点「强制」的意思了:在用户从色盘选色时,实时计算色值和背景色的对比度,如果 < 3 了,就使用 color.lightness()
API 逐步的调整颜色的明暗,确保最后界面上使用的是安全对比度的色值。
核心代码:
function calcLightColor(originColor: string) {
const white = Color('#fff')
let c = Color(originColor)
// 对比度 < 3,循环迭代使颜色越来越暗
while (c.contrast(white) < 3) {
// lightness() 可以读取/赋值颜色的 HSL 中的亮度值
// 如果是计算暗夜模式下的安全前景色,这里应该是 + 1,即让颜色越来越亮
c = c.lightness(c.lightness() - 1)
}
return c.hex()
}
function calcDarkColor(originColor: string() {
// ...
}
实际效果:
可以看到当用户选择了偏白的颜色时,明亮主题中实际使用的是灰色作为前景色,当用户选了择偏黑的颜色时,也有同样的自适应处理。
总结
在 2024 年的今天,CSS 看似已经足够强大,但是在颜色自适应类似的需求中还是略显不足,还好有 color
这个方便的 JavaScript 库帮助我们实现类似的需求。
无论背景色未知,还是前景色未知,只要设计界面时通过各种手段能保证前景色和背景色的对比度 > 3,那就可以保证界面的可阅读性。当然了,这里的安全对比度阈值是可以调整的,设为 3.5、3.75 都是可以的,但也非常不建议低于 3。
来源:juejin.cn/post/7407983735661936674
如何实现一个微信PC端富文本输入框?
微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable
来实现。
contenteditable
属性
contenteditable
是一个全局属性,表示当前元素可编辑。
该属性拥有一下三个属性值:
- true或空字符串: 表示元素可编辑
- false: 表示元素不可编辑
- plaintext-only: 表示只有原始文本可编辑,禁用富文本编辑
给div元素添加contenteditable
, 将元素变为可编辑的状态
<div contenteditable></div>
文字输入
当鼠标聚焦在输入框内时,会有outline
展示,所以要去掉该样式
<!-- index.html -->
<div contenteditable class="editor"></div>
.editor:focus {
outline:none;
}
此时文本就可以正常输入了
图片输入
图片输入分为两部分
- 截图粘贴
- 图片文件复制粘贴
有的浏览器是直接支持截图图片的粘贴的,不过图片文件的复制粘贴就不是我们想要的效果(会把图片粘贴成一个文件的图标俺不是展示图片)。
我们这里要自己做一个图片的粘贴展示,以兼容所有的浏览器。
这里我们使用浏览器提供的粘贴事件paste
来实现
paste
事件函数中存在e.clipboardData.files
属性,该属性是一个数组,当该数组大于0时,就证明用户粘贴的是文件;通过判断 file
的 type
属性是否为image/...
来确定粘贴的内容是否为图片。
const editor = document.querySelector(".editor");
/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/
function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
editor.appendChild(img);
};
reader.readAsDataURL(imageFile);
}
// 添加paste事件
editor.addEventListener("paste", (e) => {
const files = e.clipboardData.files;
const filesLen = files.length;
console.log("files", files);
// 粘贴内用是否存在文件
if (filesLen > 0) {
//禁止默认事件
e.preventDefault();
for (let i = 0; i < filesLen; i++) {
const file = files[i];
if (file.type.indexOf("image") === 0) {
// 粘贴的文件为图片
handleImage(file);
continue;
}
// 粘贴内容是普通文件
}
}
});
现在就可以粘贴图片了,无论是截图的图片还是复制的文件图片都可以粘贴到文本框内了。
现在存在的问题就是,图片尺寸太大,会按原尺寸展示图片。微信输入框展示的尺寸为最大宽高150x150
.image {
max-width: 150px;
max-height: 150px;
}
现在图片的粘贴展示部分就是解释正常的了
细心的同学注意到了,粘贴图片后光标不会向后移动,这是因为我们禁用了粘贴的默认事件,这里需要我们手动处理光标;因为我们使用了
appendChild
将图片插入到了输入框的最后面,就会出现无论光标在哪里,粘贴的图片都是出现在输入框的最后面,我们希望的是在光标所在的地方粘贴内容,接下来我们处理一下光标。
光标处理
处理光标主要以来两个WebAPI
, Selection
和 Range
- Selection:
Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通过window.getSelection()
获取Selection
对象。 具体可查阅MDN-Selection - Range:
Range
接口表示一个包含节点与文本节点的一部分的文档片段。可以使用Document.createRange
方法创建 Range。也可以用Selection
对象的getRangeAt()
方法或者Document
对象的caretRangeFromPoint()
方法获取 Range 对象。具体可查阅MDN-Range
我们使用window.getSelection
获取当前位置,通过 getRangeAt()
获取当前选中的范围,将选中的内容删除,再把我们自己的内容插入到当前的 Range
中,就实现了文件的输入。在这时插入的文件是选中状态的,所以要将选择范围折叠到结束位置,即在粘贴文本的后面显示光标。
/**
* 将指定节点插入到光标位置
* @param {DOM} fileDom dom节点
*/
function insertNode(dom) {
// 获取光标
const selection = window.getSelection();
// 获取选中的内容
const range = selection.getRangeAt(0);
// 删除选中的内容
range.deleteContents();
// 将节点插入范围最前面添加节点
range.insertNode(dom);
// 将光标移到选中范围的最后面
selection.collapseToEnd();
}
/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/
function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
- editor.appendChild(img);
+ insertNode(img);
};
reader.readAsDataURL(imageFile);
}
到这里粘贴到光标位置就正常了
文件输入
文件输入分为两部分粘贴文件和选择文件
- 粘贴文件
- 选择文件
粘贴文件
微信是以卡片的方式展示的文件,所以我们先要准备一个类似的dom结构。
/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/
function createFileDom(file) {
const size = file.size / 1024;
const templte = `
<div class="file-container">
<img src="./assets/PDF.png" class="image" />
<div class="title">
<span>${file.name || "未命名文件"}</span>
<span>${size.toFixed(1)}K</span>
</div>
</div>`;
const dom = document.createElement("span");
dom.style = "display:flex;";
dom.innerHTML = templte;
return dom;
}
.file-container {
height: 75px;
width: 405px;
box-sizing: border-box;
border: 1px solid rgb(208, 208, 208);
padding: 13px;
display: flex;
justify-content: start;
gap: 13px;
background-color: #ffffff;
}
.file-container .image {
height: 100%;
object-fit: scale-down;
}
.file-container .title {
display: flex;
flex-direction: column;
gap: 2px;
}
按照微信样式卡片准备DOM的生成函数,如上。
将生成后的DOM加入到光标位置
/**
* 处理粘贴文件
* @param {File} file 文件
*/
function handleFile(file) {
insertNode(createFileDom(file));
}
在页面上就可以看到效果啦
可以看到以上的卡片还是存在问题的,
- 光标位置不在卡片的最后面
Backspace
不会将整个卡片都删除掉
卡片的删除
因为我们的卡片是DOM,其父元素也就是输入框,开启了contenteditable
,因此它的该属性也继承了父元素的值,所以本身也是可以编辑的。分析到这里,就会涌现出一个方案——将卡片最外层 div
的contenteditable
属性值设为 false
。
但是,设置了 contenteditable="false"
后就变成了不可编辑元素,光标在他周围就不显示了🥹🥹🥹
再接着考虑,思绪一下又回到了图片身上,我们能不能做一个一样的图片呢?答案是肯定的。
说到做图片我们又会有两种方案:
- svg
- canvas
这里就考虑使用SVG制作卡片。
SVG在这里就不多介绍了,直接开始
<svg width="409" height="77" nsxml="http://www.w3.org/2000/svg">
<!-- 矩形边框 -->
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>
<!-- 右侧文件图标 -->
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>
<!-- 图标折叠三角部分 -->
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<!-- 文件类型 -->
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>
xmind
</text>
<!-- 文件名称 -->
<text x="63" y="30" font-size="16">JavaScript.xmind</text>
<!-- 文件大小 -->
<text x="63" y="52" font-size="14">206.6K</text>
</svg>
然后就得到了一个卡片
现在我们只要将该卡片使用JS封装,在上传文件的时候调用生成图片就好了。
封装如下:
/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/
function createFileDom(file) {
const size = file.size / 1024;
let extension = "未知文件";
if (file.name.indexOf(".") >= 0) {
const fileName = file.name.split(".");
extension = fileName.pop();
}
const templte = `
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>
${extension}
</text>
<text x="63" y="30" font-size="16">${file.name || "未命名文件"}</text>
<text x="63" y="52" font-size="14">${size.toFixed(1)}K</text>
`;
const dom = document.createElementNS("http://www.w3.org/2000/svg", "svg");
dom.setAttribute("width", "409");
dom.setAttribute("height", "77");
dom.innerHTML = templte;
const svg = new XMLSerializer().serializeToString(dom);
const blob = new Blob([svg], {
type: "image/svg+xml;charset=utf-8",
});
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.src = imageUrl;
return img;
}
到这里我们的文件粘贴就完成了,来展示
上传文件
上传文件又包含两部分:图片和普通文件
我们需要在这里做一个判断,如果是图片需要将其内容渲染出来,文件用卡片显示。
<div class="controls">
<img src="./assets/emoji.png" alt="表情" title="表情" />
<img
src="./assets/file.png"
alt="发送文件"
title="发送文件"
class="file"
/>
</div>
const fileDom = document.querySelector(".file");
fileDom.addEventListener("click", () => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});
以上代码,在输入框上方添加了一个文件的icon
, 给这个icon
添加点击事件,选择文件并展示。
到这里文件上传就做好了,但是还是存在问题的。
输入框的光标,在鼠标点击页面其他地方的时候会从输入框移出,如果在这时选择文件不会添加到输入框中。
所以,我们在点击文件上传的时候需要叫光标聚焦到输入框。
const fileDom = document.querySelector(".file");
fileDom.addEventListener("click", () => {
+ // 光标聚焦到输入框
+ editor.focus();
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});
到这里文件上传就完美了。
总结
以上的源码已经上传到了Github, 想要源码的小伙伴自己去拉。代码读取文件那部分可以使用Promise
进行优化。最后,欢迎大佬批评指正。
来源:juejin.cn/post/7312848658718375971
没有后端,前端也能下载文件
📋 功能介绍
- 纯前端,不涉及后端或接口
- 点击下载按钮,下载一个html文件(任何文件都可以实现),打开下载的文件展示一个的html网页
📽️ 演示Treasure-Navigation
💡思路
- 编写对应的字符串信息
- 把字符串信息转成url,赋值给a标签
- a标签设置download属性后,可以下载url中的内容信息
代码实现
- 文本信息可以随意写,最终下载的内容就是下方文本
// 定义一个包含 HTML 内容的字符串
const htmlStr = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航集合</title>
</head>
...... // 此处省略业务代码,有兴趣可以去我的项目中查看
<\/html>
`;
1. URL.createObjectURL下载
// 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// blob转成url,使用URL.createObjectURL和fileReader都可以
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBolo);
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = htmlStrUrl;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 触发链接的点击事件,开始下载
aLink.click();
// 释放之前创建的 URL 对象,释放内存
URL.revokeObjectURL(htmlStrUrl);
上面是最推荐一种方式。使用URL.createObjectURL,然后立即手动释放内存URL.revokeObjectURL(htmlStrUrl)性能高效。
2. FileReader.readAsDataURL下载
// 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = (e) => {
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = reader.result;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 生成的base64编码
aLink.click();
};
🌟理解URL.createObjectURL和FileReader.readAsDataURL
在web中处理文件和数据是常见的需求。URL.createObjectURL() 和 FileReader.readAsDataURL() 是两个用于处理文件对象的常用方法。他们都是将 File 或 Blob 对象转换成URL的形式。让我们来深入了解它们的用途和区别。
📄FileReader.readAsDataURL
- 功能概述
URL.createObjectURL(myBlob) 可以将 File 或 Blob 对象转换为临时 URL。只要没有销毁,该临时 URL 可以在任何网页中使用,网页将显示对应的 File 或 Blob 信息。 - 生命周期
该 URL 的生命周期与其创建时所在窗口的 document 绑定在一起。一旦关闭原窗口,该临时 URL 将失效。 - 内存管理
可以使用 URL.revokeObjectURL(myUrl) 提前销毁该 URL,以释放内存。
- 代码示例
// 创建blob信息
const htmlStrBlob = new Blob(["Hello World"], { type: 'text/plain' });
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBlob);
console.log(htmlStrUrl);
//在执行 revokeObjectURL 之前,htmlStrUrl可以复制到浏览器的地址栏中
URL.revokeObjectURL(htmlStrUrl);
📄FileReader.readAsDataURL
- 功能概述
FileReader.readAsDataURL() 方法用于将 File 或 Blob 对象读取为一个 Base64 编码的字符串。该字符串可以在任意网页中永久使用,网页将显示对应的 File 或 Blob 信息。 - 生命周期
生成的 Base64 字符串的生命周期是永恒的。
- 代码示例
// 创建一个 Blob 信息
const htmlStrBolo = new Blob(["Hello World"], { type: 'text/plain' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = () => {
// 这个reader.result可以复制到浏览器的地址栏中,永远可以查看对应的信息
console.log(reader.result)
}
⚖️ 区别总结
1.生成的 URL 类型
- URL.createObjectURL(): 生成一个临时的对象 URL。
- FileReader.readAsDataURL(): 生成一个 Base64 编码的数据 URL,相对临时的URL会大很多
2.性能
- URL.createObjectURL(): 性能更好,因为不需要将文件内容加载到内存中,可以使用完后立即销毁。
- FileReader.readAsDataURL(): 可能会占用更多内存,特别是在处理大文件时。
来源:juejin.cn/post/7425656340982480936
写给开发者的Material Design3
M3是Google开源的一套设计规范,主要是UI/UX设计人员设计APP的重要参考依据,与Material相比设计人员可能更偏爱苹果的设计规范,至少我认识的朋友是这样。
关于Material You的疑惑
M3还有一个名字叫Material You,至于区别ChatGPT是这样说的
Material You 是 Material Design 3 (M3) 的市场化和用户友好的名称,而 M3 则是更技术和官方的术语,两者指的是同一个设计系统,只是表达方式不同。
我推测Google为了彰显新版本的高大上,一开始起了高大上的名字Material You,后来发现还有可能发新版本,叫卖油就不妥了,又改回一贯的风格就又叫M3了。所以,现在M3官网上名字都是Material 3,偶尔在看Android文档是会有Material You,以后再看到这两个名字请不要疑惑。
一些其它的设计规范
有很多组件库遵顼Material的设计规范,像Android UI库的风格默认就是Material、还有Flutter、Web都有遵循这套设计规范的UI组件库。国内的Ant Design应该也是参考M3搞得,一开始只有Web端的React组件可以用,后来社区支持了Vue组件。最近看到字节也出了一套设计规范叫Arco Design,大家要知道这是一类xx。
Material 3
M3主要由三大部分组成:
- Foundations:规范的基础部分,包含一些核心原则。Foundations部分上来就强调了Accessibility,也就是从设计角度要尽量要满足每个人,包含正常人和残疾人(失明、听力障碍、认知障碍、情景障碍如手臂骨折)。这应是设计师的底限,却是很多APP的上限。尤其是一些单位外包APP,如某电网、某银行和一些基层单位的信息化工具,钱没少挣,设计么呵呵。以对比度为例,对于不同的字号以及图片都有不同的规范。此外还有布局规范、交互规范、Token、自定义等。
- Styles:Styles部分包含颜色、层、图标、动画、图形、排版,在 Foundations 之上应用的视觉风格,定义如何将基础元素组合和调整,以实现特定的品牌和界面风格。
- Components:使用 Foundations 和 Styles 构建的可复用 UI 元素,它们是设计和开发中实际使用的界面模块。例如:按钮、输入框、导航栏等。
关于Android UI组件库
Android的两套UI组件库,Compose和基于XML写法的组件库都实现了M3规范,需要区分一下M3中的Components和Compose中的Component。我感觉后者是前者的超集,因为Compose的代码中包含了很多在M3中没有的组件,例如Scaffold。细细想来应该是Android开发团队为了方便应用开发者快速开发,依照M3规范扩充了UI组件库中的组件。
基于XML写法的组件库是旧版本的,但是目前使用人数还是最多的。估计是切换的到新版Compose写法收益不大,另外也可能是Android开发者圈子相对前端活跃度低。换做前端圈子,早就卷起来了。我没经历过大厂,记忆中16年我就听说过React,到19年React就已经非常流行了。而Jetpack Compose都多少年了,还是半死不过。我在14年还在上大专时,接触过Android开发,那时还是使用XML定义UI,感觉极其难用,后来就没有接触了,时隔10年,Android使用Compose的开发体验已经相当开门了,然鹅这玩意为啥不火呢?而且JB让这个技术扩展到了PC端,甚至Web端,应该是值得学习的吧,难道JB还不够硬。。还是gg不够硬啊
来源:juejin.cn/post/7432866688365740041
页面跳转如何优雅携带大数据Array或Object
前言
- 在小程序或者app开发中,最常用到的就是页面跳转,上文中详细介绍了页面跳转4种方法的区别和使用,可以点击查看👉分析小程序页面导航与事件通讯。
- 而页面跳转就经常会携带数据到下一个页面,常见的做法是通过 URL 参数将数据拼接在
navigateTo
的 URL 后面。然而,这种方式在处理较大数据(如数组或对象)时会显得笨拙且有限。 - 下面将讨论通过 URL 传递参数的局限性,以及使用
EventChannel
进行数据传递的好处,并提供代码示例进行解析。
一、使用 URL 参数传递数据的局限性
在小程序中,我们通常使用 navigateTo
方法来跳转到另一个页面,并通过 URL 传递参数。例如:
// 使用 URL 参数进行页面跳转
wx.navigateTo({
url: '/pages/target/target?name=John&age=30'
});
1.1 问题
- 数据大小限制:URL 的长度限制通常在 2000 字符左右。如果需要传递一个较大的数据结构(例如一个包含大量信息的对象或数组),URL 会迅速达到限制,导致无法完整传递数据。
- 编码和解析:当数据包含特殊字符(如空格、&、=等)时,必须进行编码处理。这增加了编码和解析的复杂性,不够直观。
- 可读性差:长的 URL 会导致可读性降低,特别是当需要传递多个参数时,容易让人困惑。
二、使用 EventChannel 的优势
相比之下,使用 EventChannel
可以更优雅地在页面之间传递数据,尤其是复杂的数据结构。以下是使用 EventChannel
的几个主要优点:
- 支持复杂数据类型:可以直接传递对象和数组,无需担心 URL 长度限制或特殊字符的编码问题。
- 简化代码:代码更简洁,逻辑更清晰,特别是在需要传递多个参数时。
- 即时通信:在新页面创建后,可以立即接收数据,使得页面间的交互更加流畅。
三、示例代码解析
3.1 在源页面中
在源页面中,我们可以使用 EventChannel
传递数组和对象:
// 源页面
wx.navigateTo({
url: '/pages/target/target',
events: {
// 监听目标页面发送的消息
someEvent(data) {
console.log(data); // 可以在这里处理目标页面返回的数据
}
},
success: (res) => {
// 创建要传递的复杂数据
const arrayData = [1, 2, 3, 4, 5];
const objectData = { key: 'value', info: { nestedKey: 'nestedValue' } };
// 通过 EventChannel 向目标页面传递数据
res.eventChannel.emit('someEvent', {
array: arrayData,
object: objectData,
});
}
});
3.2 在目标页面中
在目标页面中,我们接收并使用传递的数据:
// 目标页面
const eventChannel = this.getOpenerEventChannel();
eventChannel.on('someEvent', (data) => {
// 接收数组和对象数据
console.log(data.array); // [1, 2, 3, 4, 5]
console.log(data.object); // { key: 'value', info: { nestedKey: 'nestedValue' } }
// 进行相应的数据处理
// 例如,渲染数据到页面上
this.setData({
receivedArray: data.array,
receivedObject: data.object,
});
});
四、总结
通过使用 EventChannel
进行页面间的数据传递,我们可以避免使用 URL 传递参数时面临的各种局限性,特别是在处理复杂数据时。EventChannel
使得数据传递变得更加灵活、简洁和高效,提升了小程序的用户体验。
在实际开发中,传递较少数据时,可以在url后面拼接参数进行传递使用。当需要携带大数据时可以考虑使用 EventChannel
进行复杂数据的传递,确保应用的交互更加顺畅。
来源:juejin.cn/post/7433271555830431784
2024年大龄程序员不得不面对的尴尬,也算是过来人的经验
被裁员
先说下本人情况,38,坐标杭州,具备后端架构和大数据架构能力。待过大厂,带过团队,落地过大型项目。
近几年被裁员也不算什么稀奇古怪的事情,我记得2022年下半年面试那会行情远比现在好,那会还会有猎头、企业主动找,我2022年的工作就是猎头推进去的。
然而公司运营不善,2023年底裁撤了,因为融资问题,被迫出局。
本想着年后再看工作,也想休息一段时间,于是年前就没理工作这个事。
狗不理
因为信息差,也可能因为行业这种趋势本身就是没法感知的,年后投简历发现了几个情况:
- 无论是猎聘、BOSS、智联,好像岗位都比之前少了很多,并且很多都是钉子户,我2022年找工作那会看他们挂的JD,2024年了仍然还在。
- 很多JD都要求的时间就在两个段,一个是1—3年,一个是5—10年。那么从23岁毕业来看,现在只要33岁以下的了。
- 从跟一些猎头的沟通来看,现在很多好点的岗位是需要本硕985,211都不一定看了,并且很多事明确要求硕士。这其实一刀切,放十几年前,考大学比现在难。
- 很多简历发出去直接被拒,要么是未读。基本上已经有去无回了。
一些感悟
面对这种突如其来的颠覆认知的行情,我有点措手不及,没预想自己可能就此失业了。现在的世界变化太快了,也可能我待在舒适区太久了,根本对外部世界的变化钝感迟缓。
我也没去问招聘方是什么原因,本身就个人从业经历和技能能来说,自认为还OK,但是问人家也未必会告诉你实话,在这个存量市场,势必是僧多肉少,刺刀见红,现实残酷,朝不保夕。
但是反思下十几年的职场生涯,其实多多少少还是有个人原因的,总结出来,留给后来人吧:
- 不要迷信技术,我以前以为只要技术好就是核心竞争力,就有护城河。现在发现这种信仰只有在一个崇尚技术、尊重技术的环境中才有效。但是目前看下,这个环境越来越不是,今后肯定是越来越人情社会,越来越丛林化。所以,得有综合全面的“技术”才能混,至于我说的综合全面,懂的都懂。
- IT行业不适合深耕,只适合扩展。就是你得把他当工具,千万别代入,不然你会陷入很深的工具人思维。就是你总得想着有朝一日假如你被裁员了,你是否有利用这个工具单干的能力。尤其是现在AI技术日新月异,很有可能程序员一夜之间就变成土木。
- 一个要打造个人IP,要清醒地培养自己独立赚钱的能力,跳出自己的舒适区。很可能你目前的舒适生活是由行业或平台的红利带来的,你也很可能就是那个被圈养的巨婴。想想《肖申克的救赎》那几个经典片段:坐牢越久越是离不开监狱的,到最后极度依赖,没有自己。
- 认知越早扭转越好,不要等到35岁别人不要你了才幡然醒悟,我就是反例,到这个时候怀着空杯心态再从零开始,代价不得不说有点太大了。
个人期望
最后说点自己的个人期望和未深思的规划:
1、后面还是要自己单干的,可以是独立开发、或者其他。
2、还是会热爱技术,即使如果干不了这行了,也会把它当做一个兴趣培养。
来源:juejin.cn/post/7343902139821785124
简易聊天机器人设计
1. 引言
Spring AI Alibaba 开源项目基于 Spring AI 构建,是阿里云通义系列模型及服务在 Java AI 应用开发领域的最佳实践,提供高层次的 AI API 抽象与云原生基础设施集成方案,帮助开发者快速构建 AI 应用。
2. 效果展示
源代码地址 DailySmileStart/simple-chatboot (gitee.com)
3. 代码实现
依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
注意:由于 spring-ai 相关依赖包还没有发布到中央仓库,如出现 spring-ai-core 等相关依赖解析问题,请在您项目的 pom.xml 依赖中加入如下仓库配置。
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url><https://repo.spring.io/milestone></url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
@SpringBootApplication
public class SimpleChatbootApplication {
public static void main(String[] args) {
SpringApplication.run(SimpleChatbootApplication.class, args);
}
}
配置自定义ChatClient
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
static ChatMemory chatMemory = new InMemoryChatMemory();
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
}
controller类
import ch.qos.logback.core.util.StringUtil;
import com.hbduck.simplechatboot.demos.function.WeatherService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.UUID;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
@RestController
@RequestMapping("/ai")
public class ChatModelController {
private final ChatModel chatModel;
private final ChatClient chatClient;
public ChatModelController(ChatModel chatModel, ChatClient chatClient) {
this.chatClient = chatClient;
this.chatModel = chatModel;
}
@GetMapping("/stream")
public String stream(String input) {
StringBuilder res = new StringBuilder();
Flux<ChatResponse> stream = chatModel.stream(new Prompt(input));
stream.toStream().toList().forEach(resp -> {
res.append(resp.getResult().getOutput().getContent());
});
return res.toString();
}
@GetMapping(value = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> memory(@RequestParam("conversantId") String conversantId, @RequestParam("input") String input) {
if (StringUtil.isNullOrEmpty(conversantId)) {
conversantId = UUID.randomUUID().toString();
}
String finalConversantId = conversantId;
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
return Flux.concat(
// First event: send conversationId
Flux.just(ServerSentEvent.<String>builder()
.event("conversationId")
.data(finalConversantId)
.build()),
// Subsequent events: send message content
chatResponseFlux.map(response -> ServerSentEvent.<String>builder()
.id(UUID.randomUUID().toString())
.event("message")
.data(response.getResult().getOutput().getContent())
.build())
);
}
}
配置文件
server:
port: 8000
spring:
thymeleaf:
cache: true
check-template: true
check-template-location: true
content-type: text/html
enabled: true
encoding: UTF-8
excluded-view-names: ''
mode: HTML5
prefix: classpath:/templates/
suffix: .html
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
client:
enabled: false
前端页面
<!DOCTYPE html>
<html>
<head>
<title>AI Chat Bot</title>
<style>
#chatBox {
height: 400px;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
}
.message {
margin: 5px;
padding: 5px;
}
.user-message {
background-color: #e3f2fd;
text-align: right;
}
.bot-message {
background-color: #f5f5f5;
white-space: pre-wrap; /* 保留换行和空格 */
word-wrap: break-word; /* 长单词换行 */
}
</style>
</head>
<body>
<h1>AI Chat Bot</h1>
<div id="chatBox"></div>
<input type="text" id="userInput" placeholder="Type your message..." style="width: 80%">
<button onclick="sendMessage()">Send</button>
<script>
let conversationId = null;
let currentMessageDiv = null;
function addMessage(message, isUser) {
const chatBox = document.getElementById('chatBox');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'bot-message'}`;
messageDiv.textContent = message;
chatBox.appendChild(messageDiv);
chatBox.scrollTop = chatBox.scrollHeight;
return messageDiv;
}
async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (message) {
addMessage(message, true);
input.value = '';
// Create bot message container
currentMessageDiv = addMessage('', false);
const eventSource = new EventSource(`/ai/memory?conversantId=${conversationId || ''}&input=${encodeURIComponent(message)}`);
eventSource.onmessage = function(event) {
const content = event.data;
if (currentMessageDiv) {
currentMessageDiv.textContent += content;
}
};
eventSource.addEventListener('conversationId', function(event) {
if (!conversationId) {
conversationId = event.data;
}
});
eventSource.onerror = function(error) {
console.error('SSE Error:', error);
eventSource.close();
if (currentMessageDiv && currentMessageDiv.textContent === '') {
currentMessageDiv.textContent = 'Sorry, something went wrong!';
}
};
// Close the connection when the response is complete
eventSource.addEventListener('complete', function(event) {
eventSource.close();
currentMessageDiv = null;
});
}
}
// Allow sending message with Enter key
document.getElementById('userInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
带chat memory 的对话
可以使用 InMemoryChatMemory实现
//初始化InMemoryChatMemory
static ChatMemory chatMemory = new InMemoryChatMemory();
//在ChatClient 配置memory
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
//调用时配置
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
工具
“工具(Tool)”或“功能调用(Function Calling)”允许大型语言模型(LLM)在必要时调用一个或多个可用的工具,这些工具通常由开发者定义。工具可以是任何东西:网页搜索、对外部 API 的调用,或特定代码的执行等。LLM 本身不能实际调用工具;相反,它们会在响应中表达调用特定工具的意图(而不是以纯文本回应)。然后,我们应用程序应该执行这个工具,并报告工具执行的结果给模型。
通过工具来实现获取当前天气
天气获取的类,目前使用硬编码温度
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.hbduck.simplechatboot.demos.entity.Response;
import java.util.function.Function;
public class WeatherService implements Function<WeatherService.Request, Response> {
@Override
public Response apply(Request request) {
if (request.city().contains("杭州")) {
return new Response(String.format("%s%s晴转多云, 气温32摄氏度。", request.date(), request.city()));
}
else if (request.city().contains("上海")) {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
else {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonClassDescription("根据日期和城市查询天气")
public record Request(
@JsonProperty(required = true, value = "city") @JsonPropertyDescription("城市, 比如杭州") String city,
@JsonProperty(required = true, value = "date") @JsonPropertyDescription("日期, 比如2024-08-22") String date) {
}
}
chatClient配置function
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
来源:juejin.cn/post/7436369701020516363
绑定大量的的v-model,导致页面卡顿的解决方案
绑定大量的的v-model,导致页面卡顿的解决方案
设计图如下:
页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延迟都在2-3s,人麻了老铁!!!
卡顿的原因很长一段时间都是在绑定v-model,为什么绑定v-model会很卡呢,请求到的每一条数据有14个数据需要绑定v-model,每次一请求就是100个打底,那就是1400个数据需要绑定v-model;而且组件本身也有延迟,所以这个方案不能采用,那怎么做呢?
我尝试采用原生去写,写着写着,哎解决了!!!惊呆了
做完后100条数据页面渲染不超过2s,毕竟还是需要绑定v-model,能在2s内,我还是能接受的吧;选择和输入延迟基本没有
下面就来展示一下我的代码,写的不好看着玩儿就好了:
请求到的数据:
methods这两个事件做的什么事儿呢,就是手动将数据绑定到数据上去也就是row上如图:
当然还有很多解决方案
来源:juejin.cn/post/7392248233222881316
使用wxpython开发跨平台桌面应用,对WebAPI调用接口的封装
我在前面介绍的系统界面功能,包括菜单工具栏、业务表的数据,开始的时候,都是基于模拟的数据进行测试,数据采用JSON格式处理,通过辅助类的方式模拟实现数据的加载及处理,这在开发初期是一个比较好的测试方式,不过实际业务的数据肯定是来自后端,包括本地数据库,SqlServer、Mysql、Oracle、Sqlite、PostgreSQL等,或者后端的WebAPI接口获取,本篇随笔逐步介绍如何对后端的数据接口进行建模以及提供本地WebAPI代理接口类的处理过程。
1、定义Web API接口类并测试API调用基类
我在随笔《使用wxpython开发跨平台桌面应用,动态工具的创建处理》中介绍了关于工具栏和菜单栏的数据类,以及模拟方式获得数据进行展示,如下界面所示。
如菜单数据的类信息,如下所示。
class MenuInfo:
id: str # 菜单ID
pid: str # 父菜单ID
label: str # 菜单名称
icon: str = # 菜单图标
path: str = # 菜单路径,用来定位视图
tips: str = # 菜单提示
children: list["MenuInfo"] =
这些数据和后端数据接口的定义一致,那么就很容易切换到动态的接口上。
在系统开发的初期,我们可以先试用模拟方式获得数据集合,如通过一个工具来来获得数据,如下所示。
为了更好的符合实际的业务需求,我们往往需要根据服务端的接口定义来定义调用Web API接口的信息。
我们为了全部采用Python语言进行开发,包括后端的内容,采用 基于SqlAlchemy+Pydantic+FastApi 的后端框架
该后端接口采用统一的接口协议,标准协议如下所示。
{
"success": false,
"result": T , "targetUrl": "string",
"UnAuthorizedRequest": false,
"errorInfo": {
"code": 0,
"message": "string",
"details": "string"
}
}
其中的result是我们的数据返回,有可能是基本类型(如字符串、数值、布尔型等),也有可能是类集合,对象信息,字典信息等等。
如果是分页查询返回结果集合,其结果如下所示。
展开单条记录明细如下所示。
如果我们基于Pydantic模型定义,我们的Python对象类定义代码如下所示
from pydantic import BaseModel
from typing import Generic, Type, TypeVar, Optional
T = TypeVar("T")
# 自定义返回模型-统一返回结果
class AjaxResponse(BaseModel, Generic[T]):
success: bool = False
result: Optional[T] =
targetUrl: Optional[str] =
UnAuthorizedRequest: Optional[bool] = False
errorInfo: Optional[ErrorInfo] =
也就是结合泛型的方式,这样定义可以很好的抽象不同的业务类接口到基类BaseApi中,这样增删改查等处理的接口都可以抽象到BaseApi里面了。
权限模块我们涉及到的用户管理、机构管理、角色管理、菜单管理、功能管理、操作日志、登录日志等业务类,那么这些类继承BaseApi,就会具有相关的接口了,如下所示继承关系。
2、对异步调用进行测试和接口封装
为了理解客户端Api类的处理,我们先来介绍一些简单的pydantic 入门处理,如下我们先定义一些实体类用来承载数据信息,如下所示。
from typing import List, TypeVar, Optional, Generic, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
T = TypeVar("T")
class AjaxResult(BaseModel, Generic[T]):
"""测试统一接口返回格式"""
success: bool = True
message: Optional[str] =
result: Optional[T] =
class PagedResult(BaseModel, Generic[T]):
"""分页查询结果"""
total: int
items: List[T]
class Customer(BaseModel):
"""客户信息类"""
name: str
age: int
一般业务的结果是对应的记录列表,或者实体类对象格式,我们先来测试解析下它们的JSON数据,有助于我们理解。
# 对返回结果数据格式的处理
json_data = """{
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}"""
paged_result = PagedResult.model_validate_json(json_data)
print(paged_result.total)
print(paged_result.items)
以上正常解析到数据,输出结果如下所示。
100
[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
True
如果我们换为统一返回的结果进行测试,如下所示。
json_data = """{
"success": true,
"message": "success",
"result": {
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
}"""
ajax_result = AjaxResult[PagedResult].model_validate_json(json_data)
print(ajax_result.success)
print(ajax_result.message)
print(ajax_result.result.total)
print(ajax_result.result.items)
同样的可以获得正常的输出。
True
success
100
[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
我们通过 model_validate_json 接口可以转换字符串内容为对应的业务类对象,而通过 model_validate 函数可以转换JSON格式为业务类对象。
而对于接口的继承处理,我们采用了泛型的处理,可以极大的减少基类代码的编写,如下基类定义和子类定义,就可以简单很多,所有逻辑放在基类处理即可。
class BaseApi(Generic[T]):
def test(self) -> AjaxResult[Dict[str, Any]]:
json_data = """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}"""
result = AjaxResult[Dict[str, Any]].model_validate_json(json_data)
return result
def get(self, id: int) -> AjaxResult[T]:
json_data = """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}"""
result = AjaxResult[T].model_validate_json(json_data)
return result
def getlist(self) -> AjaxResult[List[T]]:
json_data = """{
"success": true,
"message": "success",
"result": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}"""
result = AjaxResult[List[T]].model_validate_json(json_data)
return result
class UserApi(BaseApi[Customer]): pass
user_api = UserApi()
result = user_api.getlist()
print(result.success)
print(result.message)
print(result.result)
result = user_api.get(1)
print(result.success)
print(result.message)
print(result.result)
result = user_api.test()
print(result.success)
print(result.message)
print(result.result)
可以看到,子类只需要明确好继承关系即可,不需要编写任何多余的代码,但是又有了具体的接口处理。
3、实际HTTTP请求的封装处理
一般对于服务端接口的处理,我们可能需要引入 aiohttp 来处理请求,并结合Pydantic的模型处理,是的数据能够正常的转换,和上面的处理方式一样。
首先我们需要定义一个通用HTTP请求的类来处理常规的HTTP接口数据的返回,如下所示。
class ApiClient:
_access_token = # 类变量,用于全局共享 access_token
@classmethod
def set_access_token(cls, token):
"""设置全局 access_token"""
cls._access_token = token
@classmethod
def get_access_token(cls):
"""获取全局 access_token"""
return cls._access_token
def _get_headers(self):
headers = {}
if self.get_access_token():
headers["Authorization"] = f"Bearer {self.get_access_token()}"
return headers
async def get(self, url, params=):
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers=self._get_headers(), params=params
) as response:
return await self._handle_response(response)
async def post(self, url, json_data=):
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers=self._get_headers(), json=json_data
) as response:
return await self._handle_response(response)
async def put(self, url, json_data=):
async with aiohttp.ClientSession() as session:
async with session.put(
url, headers=self._get_headers(), json=json_data
) as response:
return await self._handle_response(response)
async def delete(self, url, params=):
async with aiohttp.ClientSession() as session:
async with session.delete(
url, headers=self._get_headers(), params=params
) as response:
return await self._handle_response(response)
async def _handle_response(self, response):
if response.status == 200:
return await response.json()
else:
response.raise_for_status()
这些我来基于通用ApiClient的辅助类,对业务接口的调用进行一个简单基类的封装,命名为BaseApi,接受泛型类型定义,如下所示。
class BaseApi(Generic[T]):
base_url = "http://jsonplaceholder.typicode.com/"
client: ApiClient = ApiClient()
async def getall(self, endpoint, params=) -> List[T]:
url = f"{self.base_url}{endpoint}"
json_data = await self.client.get(url, params=params)
# print(json_data)
return list[T](json_data)
async def get(self, endpoint, id) -> T:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.get(url)
# return parse_obj_as(T,json_data)
adapter = TypeAdapter(T)
return adapter.validate_python(json_data)
async def create(self, endpoint, data) -> bool:
url = f"{self.base_url}{endpoint}"
await self.client.post(url, data)
return True
async def update(self, endpoint, id, data) -> T:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.put(url, data)
adapter = TypeAdapter(T)
return adapter.validate_python(json_data)
async def delete(self, endpoint, id) -> bool:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.delete(url)
# print(json_data)
return True
我这里使用了一个 测试API接口很好的网站:jsonplaceholder.typicode.com/,它提供了很多不同业务对象的接口信息,如下所示。
统一提供GET/POST/PUT/DELETE等常规Restful动作的处理
如我们获取列表数据的接口如下,返回对应的JSON集合。
通过对应的业务对象不同的动作处理,我们可以测试各种接口。
注意,我们上面的接口都是采用了async/awati的对应异步标识来处理异步的HTTP接口请求。
上面我们定义了BaseApi,具有常规的getall/get/create/update/delete的接口,实际开发的时候,这些会根据后端接口请求扩展更多基类接口。
基于基类BaseApi定义,我们创建其子类PostApi,用来获得具体的对象定义接口。
class PostApi(BaseApi[post]):
# 该业务接口类,具有基类所有的接口
# 并增加一个自定义的接口
async def test(self) -> Db:
url = "http://my-json-server.typicode.com/typicode/demo/db"
json_data = await self.client.get(url)
# print(json_data)
return Db.model_validate(json_data)
这里PostApi 具有基类所有的接口:getall/get/create/update/delete的接口, 并可以根据实际情况增加自定义接口,如test 接口定义。
测试代码如下所示。
async def main(): post_api = PostApi()
result = await post_api.getall("posts")
print(len(result))
result = await post_api.get("posts", 1)
print(result)
result = await post_api.create(
"posts", {"title": "test", "body": "test body", "userId": 1}
)
print(result)
result = await post_api.update(
"posts", 1, {"title": "test2", "body": "test body2", "userId": 1, "id": 1}
)
print(result)
result = await post_api.delete("posts", 1)
print(result)
result = await post_api.test()
print(result)
if __name__ == "__main__":
asyncio.run(main())
运行例子,输出如下结果。
来源:juejin.cn/post/7436524595290849289
微前端原理与iframe 项目实践
一、背景
在讲微前端之前,首先了解下前端开发模式的发展历程,在最早的时候,前端的开发是耦合在服务端,主要的工作其实就是提供一个界面模板,交互也不多,实际的数据还是在服务端渲染的时候提供的。
大概在2010年,界面越来越复杂,调试、部署、都要依赖于后端,如果还是以模板的形式开发效率太低了。于是就提出了前后端分离的模式开发,这个时期也是单页应用开始火起来的时期。
到了2014左右,后端开始了微服务模式的开发,这也为微前端提供的思路。随着前端承担的东西越来越多,不断的迭代后,原本简单的单页应用,已经变成了一个巨石应用,不管是代码量还是页面量都非常庞大,一个单页应用由多个团队一起来维护。同时巨石应用还受到了非常大的约束,比如新技术的更新、打包速度等等问题。
因此,在2019年左右,借鉴微服务的思想,提出了微前端的开发模式,也就是将一个大型的单页应用,以业务域为粒度,拆分为多个子应用,最后通过微前端的技术,整合成一个完整的单页应用,同时,每个子应用也能够享有独立应用开发一致的体验。

二、微前端简介
微前端概念
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。Micro Frontends
微前端(Micro Frontends)是一种前端架构模式,借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个前端应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。
微前端的目标是使前端开发更加容易、可维护和可扩展,并且能够实现团队之间的协作。
微前端的特点
- 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
- 独立开发/部署 子应用仓库独立,单独部署,互不依赖
- 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
- 独立运行 子应用之间运行时互不依赖,有独立的状态管理
- 提升效率 微应用可以很好拆分项目,提升协作效率
- 可维护性 微前端可以更容易地进行维护和测试,因为它们具有清晰的界限和独立的代码库
劣势
- 增加了系统复杂度 需要对系统进行拆分,将单体应用拆分成多个独立的微前端应用。这种拆分可能导致系统整体变得更加复杂,因为需要处理跨应用之间的通信和集成问题
- 需要依赖额外的工具和技术 例如模块加载器、应用容器等,这些工具和技术需要额外的学习和维护成本,也可能会导致一些性能问题
- 安全性问题 由于微前端应用是独立的,它们之间可能存在安全隐患。例如,如果某个微前端应用存在漏洞,攻击者可能会利用这个漏洞来攻击整个系统
- 兼容性问题 由于微前端应用是独立的,它们之间可能存在兼容性问题。例如,某个微前端应用可能使用了一些不兼容的依赖库,这可能会导致整个系统出现问题
- 开发团队需要有一定的技术水平 实现微前端需要开发团队有一定的技术水平,包括对模块化、代码复用、应用集成等方面有深入的了解。如果团队缺乏这方面的技能,可能会导致微前端实现出现问题
三、微前端的技术实现
3.1 微前端的基础架构
微前端架构基本需要实现三个部分:
- 主应用接入子应用,包括子应用的注册、路由的处理、应用的加载和路由的切换。
- 主应用加载子应用,这部分之所以重要,是因为接入的方式决定了是否可以更高效的解耦。
- 子应用的容器,这是子应用加载之后面临的问题,包含了JS沙箱、样式隔离和消息机制。

3.2 微前端的主要技术问题
1) 构建时组合 VS 运行时组合
主框架与子应用集成的方式
微前端架构模式下,子应用打包的方式,基本分为两种:
组合方式 | 说明 | 优点 | 缺点 |
---|---|---|---|
构建时 | 子应用与主应用一起打包发布 | 构建的时候可以做打包优化,如依赖共享等 | 主子应用构建方案、工具耦合,必须一起发布,不够灵活 |
运行时 | 子应用自己构建打包,主应用运行时动态加载子应用资源 | 主子应用完全解耦,完全技术栈无关 | 有运行时损耗,多出一些运行时的复杂度 |
要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下需要使用运行时加载子应用这种方案。
2)JS Entry VS HTML Entry
子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。
App Entry | 优点 | 缺点 |
---|---|---|
HTML Entry | 1.子应用开发、发布完全独立 2.子应用具备与独立应用开发时一致的开发体验 | 1.多一次请求,子应用资源解析消耗转移到运行时 2.主子应用不处于同一个构建环境,无法利用bundle的一些构建期的优化能力,如公共依赖抽取等 |
JS Entry | 主子应用使用同一个bundle,可以方便做构建时优化 | 1.子应用的发布需要主应用重新打包 2.主应用需为每个子应用预留一个容器节点,且该节点id需与子应用的容器id保持一致 3.子应用各类资源需要一起打成一个bundle,资源加载效率变低 |
3)样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。
- BEM (Block Element Module)规范命名约束
- CSS Modules 构建时生成各自的作用域
- CSS in JS 使用 JS 语言写 CSS
- Shadow DOM 沙箱隔离
- experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器
- Dynamic Stylesheet 动态样式表
- postcss 增加命名空间
方案 | 说明 | 优点 | 缺点 |
---|---|---|---|
BEM | 不同项目用不同的前缀/命名规则避开冲突 | 简单 | 依赖约定,这也是耦合的一种,容易出现纰漏 |
CSS Modules | 通过编译生成不冲突的选择器名 | 可靠易用,避免人工约束 | 只能在构建期使用,依赖预处理器与打包工具 |
CSS in JS | CSS和JS编码在一起最终生成不冲突的选择器 | 基本彻底避免冲突 | 运行时开销,缺失完整的CSS能力 |
4)JS隔离
一个子应用从加载到运行,再到卸载,有可能会对全局产生一些污染。这些污染包括但不限于:添加 / 删除 / 修改全局变量、绑定全局事件、修改原生方法或对象等。而所有这些子应用造成的影响都可能引起潜在的全局冲突。为此,需要在加载和卸载一个应用的同时,尽可能消除这种影响。目前,主要有两种隔离方式,一种是快照沙箱、另外一种是代理沙箱。
- 快照沙箱的核心思想就是在应用挂载(mount方法)的时候记录快照,在应用卸载(unmount)的时候依据快照恢复环境。
实现的思路是直接用 window diff,把当前的环境和原来的环境做一个比较,跑两次循环(创建快照和恢复快照),然后把两个环境做一次比较,最后去全量的恢复回原来的环境。
- 代理沙箱的核心思想是让子应用里面的环境和外面的环境完全隔离。每个应用对应一个环境,比如应用A对应环境A,应用B对应环境B,同时两者之间的环境和全局环境也互不干扰。
实现思路是主要利用 ES6 的 Proxy 能力。通过劫持window,可以劫持到子应用对全局环境的一些修改,当子应用往window上挂载、删除、修改的时候,把操作记录下来,当恢复全局环境时,反向执行之前的操作。
四、微前端方案
实现方式 | 基本思想 | 优点 | 不足 | 代表方案 |
---|---|---|---|---|
路由分发 | 1.前端框架公共路由方案,映射到不同应用 2.服务器反向代理路由到不同应用 ![]() | 1. 维护、开发成本低;2.适应一些关联方不多、业务场景不发展的情况; | 不足:1.独立应用的硬聚合,有比较明显的割裂感 | -- |
前端容器化 | iframe 可以创建一个全新的独立的宿主环境,这意味着前端应用之间可以相互独立运行,仅需要做好应用之间的管理、通信即可 ![]() | 1. 比较简单,无需过多改造,开发成本低; 2.完美隔离,JS、CSS 都是独立的运行环境; 3. 不限制使用,页面上可以放多个 iframe 来组合业务 | 1. 无法保持路由状态,刷新后 iframe url 状态丢失(这点也不是完全不能解决,可以将路由作为参数拼接在链接后,刷新时去参数进行页面跳转); 2. 全局上下文完全隔离,应用之间通信困难(比如iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果); 3. iframe 中的弹窗无法突破其本身,无法覆盖全局; 4. 加载慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程 | Iframe |
前端微服务化 | 前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完成的应用![]() | 1. 应用间通信简单,全局注入; 2. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本; 3. 完备的js、css 沙箱方案,确保微应用之间的样式/全局变量/事件互相不干扰; 4. 具备静态资源预加载能力,加速微应用打开速度 | 1. 适配成本比较高,webpack工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作; 2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重; 3. 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活; 4. 无法支持 vite 等 ESM 脚本运行 | Single-SPA、qiankun |
应用组件化 | Web Components 是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外)并且在 Web 应用中使用它们,其中html imports 被废弃,可以直接加载js即可![]() | 1. 使用 类WebComponent 加载子应用相比 single-spa 这种注册监听方案更加优雅a; 2. 组件式的 api 更加符合使用习惯,支持子应用保活; 3. 降低子应用改造的成本,提供静态资源预加载能力; 4. 基于CustomElement和样式隔离、js隔离来实现微应用的加载,所以子应用无需改动就可以接入 | 1. 类 webcomponent css 沙箱依然无法绝对的隔离; 2. 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离; 3. 对于不支持 webcompnent 的浏览器没有做降级处理,兼容性不够 | micro-app |
微件化 | 微前端下的微件化是指,每个业务团队编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可![]() | 1. webpack 联邦编译可以保证所有子应用依赖解耦; 2. 应用间去中心化的调用、共享模块; 3. 模块远程 ts 支持 | 1. 需要相关规范约束各Widget; 2. 没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉; 3. 子应用保活、多应用激活无法实现; 4. 主、子应用的路由可能发生冲突 | EMP |
五、iframe 项目实践
5.1 iframe的概念以及作用
iframe(内联框架)是HTML标签,也是一个内联元素,作用是文档中的文档,或者浮动的框架(FRAME),iframe 元素会创建包含另外一个HTML文档的内联框架(即行内框架) 。
简言之,iframe可以在当前页面嵌入其他页面
5.2 优缺点
优点:
- 内容隔离:可以在同一页面中加载并显示来自不同源的内容,而不会影响主页面的功能
- 异步加载:可以异步加载iframe中的内容,不会阻塞主页面的加载
- 独立滚动:iframe内的内容可以独立滚动,不影响主页面的滚动
- 可以实现复杂的布局和组件,如广告、小工具、第三方插件等
缺点:
- 性能问题:每个iframe都会创建一个新的窗口环境,会消耗更多的内存和CPU资源
- SEO问题:搜索引擎可能无法正确索引iframe中的内容
- 跨域问题:当iframe嵌入网页与主页面不同源,会受到浏览器的安全限制,使用postMessage API需要避免发送敏感信息,或者接收来自不可信源的消息
- 历史记录问题:iframe的导航可能不会更新浏览器的历史记录,可能会影响用户的导航体验
5.3 主要属性
属性 | 值 | 描述 |
---|---|---|
src | URL | 规定在 iframe 中显示的文档的 URL |
class | classname | 规定 iframe 的类名 |
id | id | 规定 iframe 的特定id |
style | style_definition | 规定 iframe 的行内样式 |
title | text | 规定 iframe 的额外信息(可在工具提示中显示) |
width | pixels/percentage | 规定 iframe 的宽度 |
height | pixels/percentage | 规定 iframe 的高度 |
5.4 父子页面通信
5.4.1 单向通信(父传子)
URL传参: 可以在iframe的src属性中使用URL参数,将父页面的数据传递到iframe嵌入的页面。
<iframe src="http://new-iframe-url?param1=value1¶m2=value2"></iframe>
5.4.2 双向通信
父页面和子页面(即iframe内的页面)的通信机制,分为两种
(1)同源的父子页面通信:
如果父页面和子页面同源,可以直接通过JavaScript访问对方的DOM。这是因为同源策略允许同源的文档之间进行完全的交互。
父页面可以通过iframe元素的contentWindow属性获取子页面的window对象,然后直接调用其函数或访问其变量。同样,子页面也可以通过window.parent获取父页面的window对象。
父页面访问子页面:
// 获取iframe元素
const iframe = document.getElementById('myIframe');
// 获取子页面的window对象
const childWindow = iframe.contentWindow;
// 调用子页面的函数
childWindow.myFunction();
// 访问子页面的变量
console.log(childWindow.myVariable);
// 修改子页面的DOM
childWindow.document.getElementById('myElement').innerText = 'hhhh';
子页面访问父页面:
// 获取父页面的window对象
var parentWindow = window.parent;
// 调用父页面的函数
parentWindow.myFunction();
// 访问父页面的变量
console.log(parentWindow.myVariable);
// 修改父页面的DOM
parentWindow.document.getElementById('myElement').innerText = 'hhhh';
(2)不同源的父子页面通信:
如果父页面和子页面不同源,则不能直接通过JavaScript访问对方的DOM。但可以通过HTML5的postMessage API进行跨源通信。
具体来说,一个页面可以通过postMessage方法向另一个页面发送消息,然后另一个页面通过监听message事件来接收这个消息。
父页面发送消息到子页面:
var iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage('Hello', 'https://example.com');
子页面接收来自父页面的消息:
window.addEventListener('message', function(event) {
if (event.origin !== 'https://parent.com') return;
console.log('received message:', event.data);
});
5.5 项目中遇到的问题
问题描述
背景:页面初始化时,子应用iframe要从主应用获取cookie,实现免登
问题实现步骤:清除主应用的cookie,刷新页面,点击加载子应用,此时没获取到cookie,接口报鉴权错误
问题原因
1)异步获取 cookie:cookie 是通过 postMessage 从父页面异步获取的,在发送 HTTP 请求时,cookie可能尚未获取或存储在 sessionStorage 中
2)立即发送请求:在页面组件的 useEffect 钩子中,当组件挂载时立即发送请求,而不等待 cookie 的获取和存储,导致请求发出时缺少 cookie,造成请求失败
cookie交互流程

修复方案
为了确保 token 在 HTTP 请求发送之前已经成功获取并存储,需要实现以下步骤:
1)等待 token 被存储:在 httpMethod 中添加一个辅助函数,用于轮询 sessionStorage,直到 token 被存储
2)在请求之前检查并等待 token:在每个 HTTP 请求方法中,在请求实际发送之前,先调用辅助函数等待 token 被存储
具体实现
修改 api.js文件
1)创建一个waitForToken函数用于等待cookie中的 token
每隔 100ms 检查一次 sessionStorage 中是否存在 Access-Token,并返回一个 Promise。若存在 token ,调用 resolve(token) 方法将 Promise 标记为成功,并返回 token,否则等待 200 毫秒,再次检查token是否存在
const waitForToken = (timeout = 20000) => {
return new Promise((resolve) => {
const startTime = Date.now();
const checkToken = () => {
const token = window.sessionStorage.getItem("Access-Token");
if (token) {
resolve(token); //找到 token,通过 resolve 通知任务成功完成
} else if (Date.now() - startTime >= timeout) {
resolve(null); // 超时后返回 null
} else {
setTimeout(checkToken, 200); //如果没找到,每200ms检查一次
}
};
checkToken();
});
};
2)修改httpMethod
在每个请求方法里调用 waitForToken, 确保在发送请求之前获取到 token,并在获取到 token 后将其从 sessionStorage 中取出并添加到请求头中
const httpMethod = {
get: async (url, params) => {
const token = await waitForToken(); //获取token
return axios
.get(api + url, {
params: params,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
postJson: async (url, data, options = {}) => {
const token = await waitForToken(); //获取token
return axios
.post(api + url, data, {
...options,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
}
六、总结
微前端能否做到利大于弊,具体取决于实际情况和条件。对于小型、高度协作的团队和相对简单的产品来说,微前端的优势相比代价来说就很不明显了;而对于大型、功能众多的产品和许多较独立的团队来说,微前端的好处就会更突出。
工程就是权衡的艺术,而微前端提供了另一个可以做权衡的维度。
学习资料:
来源:juejin.cn/post/7435928578585264138
nextTick用过吗?讲一讲实现思路吧
源码实现思路(面试高分回答) 📖
面试官问我 Vue 的 nextTick
原理是怎么实现的,我这样回答:
在调用 this.$nextTick(cb)
之前:
- 存在一个
callbacks
数组,用于存放所有的cb
回调函数。 - 存在一个
flushCallbacks
函数,用于执行callbacks
数组中的所有回调函数。 - 存在一个
timerFunc
函数,用于将flushCallbacks
函数添加到任务队列中。
当调用 this.nextTick(cb)
时:
nextTick
会将cb
回调函数添加到callbacks
数组中。- 判断在当前事件循环中是否是第一次调用
nextTick
:
- 如果是第一次调用,将执行
timerFunc
函数,添加flushCallbacks
到任务队列。 - 如果不是第一次调用,直接下一步。
- 如果是第一次调用,将执行
- 如果没有传递
cb
回调函数,则返回一个 Promise 实例。
根据上述描述,对应的`流程图`如下:
graph TD
A["this.$nextTick(callback)"] --> B[将回调函数 callback 放入到数组 callbacks 中]
B --> C[判断是否是第一次调用 nextTick]
C -->|是| D[执行 timerFunc, 将 flushCallbacks 添加到任务队列]
C -->|否| F[如果没有 cb, 则retrun Promise]
D --> F
F --> 结束
如果上面的描述没有很理解。没关系,花几分钟跟着我下面来,看完下面的源码逐行讲解,你一定能够清晰地向别人讲出你的思路!
nextTick思路详解 🏃♂➡
1. 核心代码 🌟
下面用十几行代码,就已经可以基本实现nextTick的功能(默认浏览器支持Promise)
// 存储所有的cb回调函数
const callbacks = [];
/*类似于节流的标记位,标记是否处于节流状态。防止重复推送任务*/
let pending = false;
/*遍历执行数组 callbacks 中的所有存储的cb回调函数*/
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/*执行所有cb回调函数*/
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}
function nextTick(cb) {
// 将回调函数cb添加到 callbacks 数组中
callbacks.push(() => {
cb();
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有flushCallbacks被推送到任务队列中去则不需要重复推送
pending = true;
// 使用 Promise 机制,将 flushCallbacks 推送到任务队列
Promise.resolve().then(flushCallbacks);
}
}
测试一下:
let message = '初始消息';
nextTick(() => {
message = '更新后的消息';
console.log('回调:', message); // 输出2: 更新后的消息
});
console.log('测试开始:', message); // 输出1: 初始消息
如果你想要应付面试官,能手写这部分核心原理就已经差不多啦。
如果你想彻底掌握它,请继续跟着我来!!!🕵🏻♂
2. nextTick() 返回promise 🌟
我们在开发中,会使用await this.$nextTick();让其下面的代码全部变成异步代码。
比如写成这样:
await this.$nextTick();
......
......
// 或者
this.$nextTick().then(()=>{
......
})
核心就是nextTick()如果没有参数,则返回一个promise
const callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false;
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
callbacks.length = 0;
}
function nextTick(cb) {
// 用于存储 Promise 的resolve函数
let _resolve;
callbacks.push(() => {
/* ------------------ 新增start ------------------ */
// 如果有cb回调函数,将cb存储到callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数cb不存在,则保存promise的的成功回调resolve
_resolve();
}
/* ------------------ 新增end ------------------ */
});
if (!pending) {
pending = true;
Promise.resolve().then(flushCallbacks);
}
/* ------------------ 新增start ------------------ */
if (!cb) {
return new Promise((resolve, reject) => {
// 保存resolve到callbacks数组中
_resolve = resolve;
});
}
/* ------------------ 新增end ------------------ */
}
测试一下:
async function testNextTick() {
let message = "初始消息";
nextTick(() => {
message = "更新后的消息";
});
console.log("传入回调:", message); // 输出1: 初始消息
// 不传入回调的情况
await nextTick(); // nextTick 返回 Promise
console.log("未传入回调后:", message); // 输出2: 更新后的消息
}
// 运行测试
testNextTick();
3. 判断浏览器环境 🔧
为了防止浏览器不支持 Promise,Vue 选择了多种 API 来实现兼容 nextTick:
Promise --> MutationObserver --> setImmediate --> setTimeout
- Promise (微任务):
如果当前环境支持 Promise,Vue 会使用Promise.resolve().then(flushCallbacks)
- MutationObserver (微任务):
如果不支持 Promise,支持 MutationObserver。Vue 会创建一个 MutationObserver 实例,通过监听文本节点的变化来触发执行回调函数。 - setImmediate (宏任务):
如果前两者都不支持,支持 setImmediate。则:setImmediate(flushCallbacks)
注意
:setImmediate 在绝大多数浏览器中不被支持,但在 Node.js 中是可用的。 - setTimeout (宏任务):
如果前面所有的都不支持,那你的浏览器一定支持 setTimeout!!!
终极方案:setTimeout(flushCallbacks, 0)
// 存储所有的回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;
/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/* 执行所有 cb 回调函数 */
for (let i = 0; i < callbacks.length; i++) {
callbacks[i](); // 依次调用存储的回调函数
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}
// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;
if (typeof Promise !== "undefined") {
// 创建一个已resolve的 Promise 实例
var p = Promise.resolve();
// 定义 timerFunc 为使用 Promise 的方式调度 flushCallbacks
timerFunc = () => {
// 使用 p.then 方法将 flushCallbacks 推送到微任务队列
p.then(flushCallbacks);
};
} else if (
typeof MutationObserver !== "undefined" &&
MutationObserver.toString() === "[object MutationObserverConstructor]"
) {
/* 新建一个 textNode 的 DOM 对象,用 MutationObserver 绑定该 DOM 并指定回调函数。
在 DOM 变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),
即 textNode.data = String(counter) 时便会加入该回调 */
var counter = 1; // 用于切换文本节点的值
var observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
var textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined") {
/* 使用 setImmediate 将回调推入任务队列尾部 */
timerFunc = () => {
setImmediate(flushCallbacks); // 将 flushCallbacks 推送到宏任务队列
};
} else {
/* 使用 setTimeout 将回调推入任务队列尾部 */
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 将 flushCallbacks 推送到宏任务队列
};
}
function nextTick(cb) {
// 用于存储 Promise 的解析函数
let _resolve;
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
// 如果有 cb 回调函数,将 cb 存储到 callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数 cb 不存在,则保存 Promise 的成功回调 resolve
_resolve();
}
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有 nextTickHandler 被推送到任务队列中去则不需要重复推送
pending = true;
// 调用 timerFunc,将 flushCallbacks 推送到合适的任务队列
timerFunc(flushCallbacks);
}
// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
// 保存 resolve 到 callbacks 数组中
_resolve = resolve;
});
}
}
你真的太牛了,居然几乎全部看完了!
Vue纯源码
上面的代码实现,对于 nextTick 功能已经非常完整了,接下来我将给你展示出 Vue 中实现 nextTick 的完整源码。无非是加了一些判断变量是否存在的判断。看完上面的讲解,我相信聪明的你一定能理解 Vue 实现 nextTick 的源码了吧!💡
// 存储所有的 cb 回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;
/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
pending = false; // 重置标记,允许下一个 nextTick 调用
const copies = callbacks.slice(0); // 复制当前的 callbacks 数组
callbacks.length = 0; // 清空 callbacks 数组
for (let i = 0; i < copies.length; i++) {
copies[i](); // 执行每一个存储的回调函数
}
}
// 判断是否为原生实现的函数
function isNative(Ctor) {
// 如Promise.toString() 为 'function Promise() { [native code] }'
return typeof Ctor === "function" && /native code/.test(Ctor.toString());
}
// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;
if (typeof Promise !== "undefined" && isNative(Promise)) {
const p = Promise.resolve(); // 创建一个已解决的 Promise 实例
timerFunc = () => {
p.then(flushCallbacks); // 使用 p.then 将 flushCallbacks 推送到微任务队列
// 在某些有问题的 UIWebView 中,Promise.then 并不会完全失效,
// 但可能会陷入一种奇怪的状态:回调函数被添加到微任务队列中,
// 但队列并没有被执行,直到浏览器需要处理其他工作,比如定时器。
// 因此,我们可以通过添加一个空的定时器来“强制”执行微任务队列。
if (isIOS) setTimeout(() => {}); // 解决iOS 的bug,推迟 空函数 的执行(如果不理解,建议忽略)
};
} else if (
typeof MutationObserver !== "undefined" &&
(isNative(MutationObserver) ||
MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
let counter = 1; // 用于切换文本节点的值
const observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
const textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks); // 使用 setImmediate 推送到任务队列
};
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 使用 setTimeout 推送到宏任务队列
};
}
function nextTick(cb, ctx) {
let _resolve; // 用于存储 Promise 的解析函数
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx); // 执行传入的回调函数
} catch (e) {
handleError(e, ctx, "nextTick"); // 错误处理
}
} else if (_resolve) {
_resolve(ctx); // 解析 Promise
}
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
pending = true; // 改变标记位的值
timerFunc(); // 调用 timerFunc,调度 flushCallbacks
}
// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve; // 存储解析函数
});
}
}
总结
通过这样分成三步、循序渐进的方式,我们深入探讨了 nextTick 的原理和实现机制。希望这篇文章能够对你有所帮助,让你在前端开发的道路上更加得心应手!🚀
来源:juejin.cn/post/7433439452662333466
2023总结:30岁,结束8年北漂回老家,降薪2/3,我把人生过的稀烂
一转眼又快过年了,回想整个23年,简直是我人生中最黑暗的一年(之一)。
23年,我30岁,在北京干了8年程序员。30岁这年我做了一个决定:结束8年北漂生涯,回老家(一个三线城市)自己创业,去做自媒体。
一、为何做出这个决定
这个决定也不是一时拍脑袋做出的决定,导火索是在22年:
那时候大环境不好,大家都越来越卷,下班的时间也越来越晚。
放假回家亲戚朋友总说,你在北京996这么累,图啥啊,工资是高点,但是完全没有生活啊。而且你在北京漂到啥时候是个头?你又买不起房,又没户口,早晚得回来吧。
我仔细想想也有道理,活了这么多年了都在当牛做马,被pua,还得面临35岁危机,真的受够这种生活了!所以那时候心里埋下了一颗种子:我要去浪浪山的那边看看!
其实我本身就是一个喜欢自由的人,这么多年那句“打工是不可能打工的,这辈子都不会打工”一直激励着我,我想自己有一天也能实现不打工这个目标。
于是22年底我做了一个决定:23年去山的那边看看大海的样子!拿完年终奖就辞职!去创业,去开启我的新的人生!
在准备辞职前的几件事情,都让我更加坚定了辞职的决心:
- 那时候还没有放开,在家线上办公,本来在公司办公是995,晚上9-10点下班了基本就没啥事情了,但是在家就不一样了,每天各种电话、视频会议,甚至十一二点都要开会,恨不得让你24h都在线,生活和工作基本都没有边界。那个时候只要听到会议呼叫的声音,内心就一紧,心中默默祈祷不要出什么幺蛾子,都快成心理阴影了。
- 当时得了新冠也只敢请了一天假,第二天晕晕乎乎的就继续开始工作了。因为我知道,落下的工作最后还得你自己加班完成,否则领导最后还会赖你延期。
- 周末也需要随时在线,需要及时回复群里的消息,需要随时解决线上的问题,否则就会打上工作态度不好的标签,绩效肯定低。导致我周末基本不敢出去,出去也得随时看着手机,看有没有@你的消息,整天提心吊胆,玩也玩不好,还不如在家躺着。
我觉得这不是我想要的生活,每天太累了,身体累不算,心还累,生怕自己负责的业务出了什么问题,如坐针毡,如芒刺背,如履薄冰。
二、我辞职了
终于,熬到23年,拿到年终奖后,我决定提出离职。
当时身边有些人劝我不要辞职,说现在环境不好,更不应该放弃你的老本行,去做啥自媒体。
我当时内心嗤之以鼻,心想程序员这行也就干到35岁,而且现在卷的不行,加班加的身体都快废了,这行岁数大了没前途!我趁现在30岁还年轻,创业正值当年,辞职改行的选择非常有战略眼光!(当时真的是感觉杰克马附体,准备在这个三十而立的年纪,大干一场!)
当然我也不是脑袋一热就想着辞职去做自媒体,辞职前我做了充足的准备,和很长时间的调研&分析:
- 我作为一个互联网人,做实体店肯定不是我擅长的,肯定只能从互联网上选择行业,互联网项目有很多:个人工具站,知乎好物,闲鱼二手书,小红书带货,抖音带货,抖音个人ip,公众号写作,短剧cps,小说推文,知识付费等等的项目,我可以说都看了一个遍,其中抖音现在流量最大,要做风口上的猪,做抖音相关肯定要容易很多。
- 然后我也学习了一些创业相关的知识,比如如何找对标,如何分析对方商业模式,参加了很多知识付费的圈子,然后还报了小红书和抖音的培训班,总共加起来得有1w多呢。
- 而且我还预留了充足的资金,我做了最坏的打算,就算我一分钱不挣,也够我活3年呢,我不会3年都创业不成功吧!(此处白岩松表情包:不会吧!.jpg)
三、想象很美好
为了这次创业,我还制定了计划,年度计划,季度计划,月计划,周计划,天计划,真的非常详细。
我也要很自律,每天按时起床,锻炼,学习,做业务。这次我真的抱着必胜的决心来做!
当然我也提前列出可能要遇到的风险,并思考解决方案:
比如项目进展慢怎么办,拖延症怎么办,家人反对怎么办,朋友约吃饭打乱我的计划怎么办,遇到困难我该怎么应对等等
这么一套组合拳下来,我觉得已经万事俱备,只差开干了!
四、现实很残酷
4月我如期辞职,当时正值清明节,淅淅沥沥的小雨并没有浇灭我开启新生活的热情。辞职后,我就按计划开始早睡早起,锻炼,学习,搞创业的事情。
但是马上就被打脸了,这是我创业中遇到的第一个难题,也是我万万没有预料到的
就在我创业后的不久,我患上焦虑症,失眠了,而且还很严重,就是那种从晚上11点躺下,躺到早上6点才睡着的那种失眠,而且还时不时的心悸。
我万万没想到会患上失眠症。因为我觉得没有上班的压力了,想啥时候干活就啥时候干活,想干多少干多少,想啥时候下班就啥时候下班,也没人pua我了,还有时间锻炼,应该睡得更好才是。
但实际并不是这样,对于一个从小被学校管,长大了被公司管的芸芸众生来说,创业实际是这样的:
- 你会非常忙,比上班还要忙,因为你之前是螺丝钉,做好自己的本职工作就好了,现在事无巨细,都你一个人。比如做自媒体,从开始的账号定位-》内容选题-》写脚本-》置景&拍摄-》后期剪辑-》选品-》商务对接-》客服-》用户社群运营,所有的环节,都得你自己一个人。然后视频没流量怎么办,违规了怎么办,付费转化率低怎么办,还是只有你自己去解决。(之前公司让你干啥你干啥,你只需要规定时间完成你的任务就好了)
- 面对大量的自由时间,你根本不会支配时间,因为很多环节你都是小白,要学习的东西太多,但是你天天光学习了,每天看似很忙,但是看不到产出,导致你就很沮丧。(之前你只做熟悉的工作,产出是有保证的)
- 行动困扰,没有目标感,没有人给你一个目标&方向,你不知道你现在做的事情对挣钱有没有价值,你会迷茫,你会时常自我怀疑。(之前你只是专注领导安排的任务,至于这个任务能不能帮公司挣到钱,那是公司的事情,你关心到手的工资而已)
- 没有成就感,认同感。因为现在你很多事情要从0开始,比如写文案要求写作能力,拍视频要求表现力,搞流量要求你有运营&营销的能力 ,相比之前做熟悉工作,感觉上会有落差(之前工作中都是做你擅长的领域,每完成一项任务也很有成就感,做的出色还能收获同事和领导的认可)
- 和社会断了链接,没有存在感,归属感(这是人类的基本需求之一),你不属于任何一个群体,没有人赞扬,尊重,接纳你,甚至你想被骂两句也没人鸟你(之前在公司,做的好了领导或者同事会夸你两句,做的不好了可能会给你建议,起码有人能倾诉,能交流,能寻求帮助)
- 没有了收入,眼见钱包一天天变少,你肯定会焦虑。但是更让你焦虑的,是你不知道未来什么时候能挣到钱。更更让你焦虑的,是不知道最后能不能挣到钱。(之前工作压力不管有多大,多累,起码你还有工资,你还有吃饭的钱,这是底气)
所以在此奉劝有裸辞创业想法的人,千万不要裸辞!裸辞创业九死一生! 正确的做法是一边工作愿一边做副业,等副业的收入和工资差不多甚至超过工资了,再去辞职。有人会说,我工作那么忙,根本没时间搞副业啊。我之前也是这么想的,但是现在我会告诉你:没有时间就去挤出时间,每天晚睡或者早起一会,周末也抽出时间搞。这点问题都解决不了?创业的遇到问题会比这难十倍!如果这个你觉得太难了,那我劝你还是老老实实打工吧。
但是我已经裸辞了,没办法,只能去解决问题,我开始吃助眠药,喝中药,有些好转,但也没治好,只是比之前好点。
就这么拖着残血的半条命,我坚持了半年多,一半时间学习,一半时间实践,搞了两个自媒体号,第一个号违规被封了,第二个号流量也没啥起色。这条路是越走越看不到希望,每天晚上都不想睡觉,因为害怕明天的到来,因为明早一起床,眼前又是一片黑暗。
五、彻底崩溃
11月,因为种种原因和媳妇生了一场气,我觉得对于我创业,她不鼓励也就算了,在我状态这么差的情况下还不能对我包容一点,甚至有点拆后台的感觉,那几天我就像一个泄了气的皮球,内心被彻底击垮了。(所以现在有点理解每个成功男人的背后,都有一个伟大的女人
这句话的含义了)
终于,在创业的压力,8个月没有收入的恐慌,焦虑失眠心悸的折磨中,我决定放弃了。
失败了,彻彻底底的失败。回想这次经历,就好像之前在一艘航行的货轮上打工,然后受不船上的种种压榨,终于鼓起勇气,自己带着一艘救生艇,跳海奔向自己想要的自由。结果高估了自己的目前的实力,经不起茫茫大海狂风骤雨,翻船了。。濒临溺亡。。。
六、重新找工作
放弃后的那几周,我开始熬夜,开始暴饮暴食,之前的运动也放弃了。整天在家里拉着窗帘,除了吃饭就是躺在床上刷手机,让我尽可能分散注意力,减少内心的痛苦。
但是这样的状态也不是事儿啊,目前肯定是不想再去面对创业的事情了,那只能去找个工作先干着了。
刚开始找工作内心又有不甘,因为一个三线城市比起北京来说,不管是工作机会,环境,薪资来说,都差太多。
但是没办法,我现在的感觉就是快溺死了,我现在急需一个救命稻草,活下来,是我目前的首要任务。
于是在网上海投了一遍,结果惨不忍睹,根本没几家公司招人,前前后后一个月,真正靠谱的面试就一家,是的,只有一家。
好在这家也顺利拿了offer,是一家刚创业的公司,一共十几个人,薪资只有原来1/3多点,但是拿到offer那一刻我依然有些激动,我感觉我活下来了,不管怎样,现在能喘口气了。
七、迷茫的未来
现在上班已经一个多月了,公司挺好,不加班,基本上7点前就都走了,离家也挺近,骑个共享单车也就10分钟。这一个月,焦虑没了,不心悸了,失眠也好了。每天就是按部就班上下班,完成老板给的任务,其他的事情也不用自己操心,终于又做起自己熟悉且擅长的事情。
但是内心还是有落差,本来北京好好的工作自己作死给辞了,要不这一年也能攒不少钱呢,现在不但钱没了,这几个月还花了好几w,最后还差点嘎了。
其实入职这家公司前,北京之前的同事问我要不要回去,说现在正忙,我说你先问问吧。
我当时也纠结,如果真的能回去,我还要不要回去,毕竟在那边挣一个月顶这边仨月。但是回都回来了,再去北京可能就一辈子留北京了吧。
不过后来同事说年前没有招人计划了,可能要年后了,如果招人到时再联系我。正好我不用纠结了,这可能就是命运的安排吧。
不过真的想问问你们,如果到时有机会,是继续北漂呢,还是选择在老家呢?
八、结语
说实话,我现在知道了,山的那边还是山,我不知道什么时候才能看到海,甚至我可能一辈子都看不到海了。不过目前想的就是,调整好状态,先走一步算一步吧。
30岁的年纪,学会和自己和解,学会接受自己的平庸,但是依然要努力,毕竟在这个阴雨连天的环境下,没有伞的孩子只能努力奔跑。
来源:juejin.cn/post/7330439494666453018
用Vant组件库来做移动端UI界面能有多方便?🚀🚀🚀
前言
最近写一个移动端的项目,用到了vant做UI界面设计,不得不再次感叹开源的力量,这个组件库封装的实在是太优雅了,只要你愿意看官方文档,就不会担心看不懂,也不会担心不会用,接下来就带大家去浅尝一下这个组件库。官网文档:Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)
Vant
Vant 是一个轻量、可定制的移动端组件库,于 2017 年开源。
可以看到,移动端
,用来做移动端
的ui界面。
目前 Vant 官方提供了 Vue 2 版本、Vue 3 版本和微信小程序版本,并由社区团队维护 React 版本和支付宝小程序版本。
特性
- 🚀 性能极佳,组件平均体积小于 1KB(min+gzip)
- 🚀 80+ 个高质量组件,覆盖移动端主流场景
- 🚀 零外部依赖,不依赖三方 npm 包
- 💪 使用 TypeScript 编写,提供完整的类型定义
- 💪 单元测试覆盖率超过 90%,提供稳定性保障
- 📖 提供丰富的中英文文档和组件示例
- 📖 提供 Sketch 和 Axure 设计资源
- 🍭 支持 Vue 2、Vue 3 和微信小程序
- 🍭 支持 Nuxt 2、Nuxt 3,提供 Nuxt 的 Vant Module
- 🍭 支持主题定制,内置 700+ 个主题变量
- 🍭 支持按需引入和 Tree Shaking
- 🍭 支持无障碍访问(持续改进中)
- 🍭 支持深色模式
- 🍭 支持服务器端渲染
- 🌍 支持国际化,内置 30+ 种语言包
没错,这是官网复制来的介绍。
那么从官方文档可以看到目前这是Vant4.x,适用于vue3,如果你用的是vue2,那么就用Vant2。我们切换到快速上手,就能看到如何快速入门使用
Vant使用
1.安装
在现有项目中使用 Vant 时,可以通过 npm
进行安装:
# Vue 3 项目,安装最新版 Vant
npm i vant
# Vue 2 项目,安装 Vant 2
npm i vant@latest-v2
看得出来,非常优雅,最新版直接安装vant,如果不是最新的才加了些-2...
当然,你也可以通过 yarn
、pnpm
或 bun
进行安装:什么方式都有,大家可以自己参照官方文档。
# 通过 yarn 安装
yarn add vant
# 通过 pnpm 安装
pnpm add vant
# 通过 Bun 安装
bun add vant
那么引入之后如何使用呢?我们常常需要做一个登陆注册页面,看看有了vant组件库之后,我们还需要去切页面吗?
此处我们需要放一个表单,接下来我们查阅官方文档,看看vant的表单如何使用。
2.引入
写的很清楚,表单会用到三个组件,我们只需要从vant库中引入这三个组件,然后都use掉即可
import { createApp } from 'vue';
import { Form, Field, CellGr0up } from 'vant';
const app = createApp();
app.use(Form);
app.use(Field);
app.use(CellGr0up);
引入之后,就可以在我们需要用到表单的页面使用了。直接将他写好的表单复制到我们的代码中
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="username"
name="用户名"
label="用户名"
placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]"
/>
<van-field
v-model="password"
type="password"
name="密码"
label="密码"
placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
可以看到,它使用了v-model双向绑定,因此我们就需要自己去定义一个username、passwor...看需要用到什么,然后官方文档就直接了当告诉我们了这些变量
import { ref } from 'vue';
export default {
setup() {
const username = ref('');
const password = ref('');
const onSubmit = (values) => {
console.log('submit', values);
};
return {
username,
password,
onSubmit,
};
},
};
- 注意他这里提交表单事件,直接接收了一个参数values,事实上这个values可以直接通过values.name,我们input框上的name属性代表的input框拿到的数据,在这里其实就是values.用户名,所以我们可以直接改成英文,如:values.username。
在官方文档上也能看见他的样式,甚至可以做校验
除此之外,再介绍一个,例如微信中点击某个东西成功之后,就会弹出一个弹出框,打勾,或者失败,如果这么一个UI界面要我们自己去写可能多少也有点麻烦,但是vant都给我们封装好了
引入
通过以下方式来全局注册组件,更多注册方式请参考组件注册。
import { createApp } from 'vue';
import { Toast } from 'vant';
const app = createApp();
app.use(Toast);
显然就是引入一个封装好了的Toast组件,然后use掉,vant官方都给我们想好了,一般这种弹出提示框都是我们js中做判断,如果成功就弹出,因此一般都出现在函数里面,官方就直接给我们打造了函数调用的方式
import { showToast } from 'vant';
showToast('提示内容');
全局引入依赖之后,在js中直接引入,然后直接调用就能出现效果,帮助咱们切图仔省去了不少麻烦事儿。
除了成功、失败,他甚至可以自定义写图标,也是极度舒适了。
看看我的登录页面是如何使用的:
<template>
<div class="login">
<h1>登录</h1>
<div class="login-wrapper">
<div class="avatar">
<img src="https://q6.itc.cn/q_70/images03/20240601/80b789341c9b45cb8a76238650d288a5.png" alt="">
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field v-model="username" name="username" label="用户名" placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]" />
<van-field v-model="password" type="password" name="password" label="密码" placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]" />
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from '@/api/index.js'
const password = ref('')
const username = ref('')
// 登录
const onSubmit = (values) => {
// console.log(values); // 用户输入的账号和密码对象 name作为key
// 发送接口请求给后端 校验数据
axios.post('/user/login', {
username: values.username,
password: values.password
}).then((res) => {
console.log(res);
})
}
</script>
<style lang="less" scoped>
.login {
width: 100vw;
height: 100vh;
background-color: #fff;
padding: 0 0.3rem;
box-sizing: border-box;
overflow: hidden;
h1 {
height: 0.6933rem;
text-align: center;
font-size: 0.48rem;
margin-top: 1.12rem;
}
.login-wrapper {
width: 7.44rem;
height: 10.77rem;
border: 1px solid rgba(187, 187, 187, 1);
margin: 0 auto;
margin-top: 1.7rem;
border-radius: 0.3rem;
box-shadow: 0 0 0.533rem rgba(170, 170, 170, 1);
.avatar {
width: 2.4rem;
height: 2.4rem;
margin: 1rem auto 0.77rem auto;
border-radius: 50%;
overflow: hidden;
img {
width: 100%;
}
}
}
}
:deep(.van-cell__title.van-field__label) {
width: 45px;
}
</style>
非常方便,跟着上述步骤,官方文档写的很清楚,直接贴上去就实现了,然后自己再加一些less样式。
那么其他的就不做过多介绍了,大家在做项目需要用到一些东西的时候,直接查阅官方文档,直接拿去用即可。Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)
在这里我写的都是rem单位,目的是做移动端的适配,根据宽度调整fontsize的大小,其实也可以通过vant封装好的移动端适配组件来做,这里不做过多介绍了。
小结
Vant 是一个轻量、可靠的移动端组件库,可用于快速构建风格统一的移动端 UI 界面。它提供了一系列高质量的组件,涵盖了导航、表单、按钮、列表、轮播图等常用功能。
我们只需要安装依赖后引入依赖还有在全局中引入他的整份css代码,最后按需取组件,就能轻松完成ui界面设计。
来源:juejin.cn/post/7389577588174503936
即梦AI推出“一句话改图”功能,助力用户发掘更多创意
近日,字节跳动旗下AI内容平台即梦AI上线了指令编辑功能,用户使用即梦AI的“图片生成”功能时,在上传导入参考图片后,选择“智能参考”,在文本框中输入想要如何调整图片的描述,就可以轻松对图片进行编辑。目前,该功能在即梦AI网页版和App移动端均可免费体验。
图说:即梦AI网页版指令编辑功能使用示意
据介绍,即梦AI的指令编辑功能支持包括修图、换装、美化、转化风格、在指定区域添加删除元素等各类编辑操作,通过简单的自然语言即可编辑图像,大幅降低了用户操作成本,有助于用户发掘和实现更多创意。
图说:即梦AI指令编辑功能创意玩法
据介绍,即梦AI的指令编辑功能由字节最新通用图像编辑模型SeedEdit支持。SeedEdit是国内首个实现产品化的通用图像编辑模型。过往,学术界在文生图和图生图领域已有较多研究,但做好生成图片的指令编辑一直是难题,二次修改很难保证稳定性和生成质量。今年以来,Dalle3、Midjourney接连推出产品化的生图编辑功能,相较业界此前方案,编辑生成图片的质量大大改善,但仍缺乏对用户编辑指令的精准响应和原图信息保持能力。SeedEdit在通用性、可控性、高质量等方面取得了一定突破,适用各类编辑任务,支持用户脑洞大开的奇思妙想,无需再训练微调即可快捷应用。
即梦AI支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。近期,即梦AI还面向用户开放了字节视频生成模型Seaweed的使用。
该平台相关负责人表示,AI能够和创作者深度互动,共同创作,带来很多惊喜和启发,即梦AI希望成为用户最亲密和有智慧的创作伙伴。(作者:李双)
收起阅读 »开发人员,千万不要去碰那该死的业务参数,无论什么时候!
你好呀,我是歪歪。
前几天发了一个牢骚:
本来只是单纯的吐槽一下,但是好多人对其中的细节比较感兴趣。
大家都是搞技术的嘛,对于“踩 BUG”这种喜闻乐见的事情,有兴趣是很正常的。
其实我这个 BUG,其实严格意义上不能叫做 BUG,因为和程序无关,甚至和技术的关系都不算大。从标题上你也能猜出来,是和一个业务参数相关。
但是在这个过程中,因为我是整个事件全程的亲历者,所以现在回看这个事情,我还是有一些思考在里面的。
我觉得这是一个程序员会遇到的“典型事件”。
那就用这篇文章一起复盘一下吧。
背景
要说明这个问题的背景,甚至不需要一个具体的业务场景,只需要围绕着以下这个非常常见的利息计算公式,就可以说明问题的起因:
利息=计息金额*日利率。
日利率=年利率/360
由于日利率的计算,涉及到除法,在对应需求第一次开发时,业务的要求是日利率保存 7 位小数。
在程序中,年利率和日利率是两个字段分别保存的,日利率在初始化的时候就算好落库了,后续程序直接取这个算好的日利率就行了。
系统上线,相安无事。
跑了一段时间后,业务又提来一个需求:当前的精度不够,需要调整到 11 为小数。
你不用好奇歪师傅这边到底是什么业务场景,反正我去看了业务数据,需求是合理的,那就把需求接过来干就行了。
保存 7 位小数和 11 位小数,大家都是搞开发的,肯定也知道这个就是一个小改动,很快就能搞定。
事实也是如此,虽然之前的需求对应的代码不是我写的,但是我看过代码,清楚的知道改动点在哪,所以很快就开发完成。
前面说了,这个需求之前在线上按照 7 位小数跑了一段时间,所以存在一些存量配置。
针对这些存量数据,在需求评审会议上的时候,我提了一句:存量配置怎么处理呢?
业务答复:这次需求上线的时候,你按照 11 位小数重新算好,然后写 SQL 更新一下就行。
我心里一盘算:计算公式明确,年利率我也有,算一把,没啥问题。
就答应了。
然后,不出意外的出意外了。
假设年利率是 2.5%,除以 360 之后,保留 11 位小数,应该是 0.00006944444。
而我不知道当时为什么手抖了,在 SQL 里面写成了 0.00069444444。
我给你对比一下:
0.00006944444
0.00069444444
相当于我写出来的日利率被扩大了十倍。
然后再回头看看这个公式:
利息=计息金额*日利率
日利率被扩大十倍,那么对应的计提金额也会被扩大十倍。
这就是问题的背景。
一个单纯的人为失误,和程序没有任何关系,所以严格意义上不属于程序 BUG。
但是这个问题确实是足够低级。
为什么没被发现?
那么这个错误的 SQL 是怎么通过代码评审、测试验证这两道关卡被带到生产环节的呢?
首先,这一次提交的代码,压根就没有评审环节。
我有代码提交权限,也有代码审核权限。所以我自己提交,自己就审核通过了。
其实这个需求应该是组里面另外一个小伙伴来做,但是当时他被调到其他组了。
他还在我们组的时候,我们的合作模式是他提交代码,我进行审核。
如果有这个环节,我觉得我有 50% 的几率发现问题。
为什么是 50% 呢?
因为这取决于我审核代码时是否有正在处理其他的事情,如果有其他事情处理,我可能会形式主义的看上几眼。如果没有其他事情,而这次提交的代码量又不大的话,我基本上都会认真的过一下提交的内容。
通过代码评审之后,接下来就应该是测试环节。
测试主要关注的是精度从 7 位变成 11 位之后,最终计算出来的利息是否符合预期。
他测试时是走了整个业务的全流程。
在“全流程”中,这个 11 位精度的日利率,是在页面配置年利率的时候通过程序自动计算出来的,不会错的。
而他在验证 SQL 语句的时候,测试环境又没有生产环境的配置,所以他拿着我提供的 SQL,只能保证写的语法没问题,能正常执行,并不能确保里面数据的正确性。
而我也记得很清楚,我当时给他说过:你执行一下 SQL 不报错就行,值的正确性,我来保证。
而且戏剧性的是,测试同事很仔细的去看了值,他去数了确实是 11 位小数。但是可惜,站在他的视角,他发现不了值被扩大了十倍。
所以,测试环节也没有发现这个问题:
0.00006944444
0.00069444444
就带着上生产了。
一个问题正常来说不应该被带上生产,但是我们确实不能保证测试环节一定能把所有问题都测出来,所以新项目、新迭代的生产验证也是非常有必要的。
这个我们也做了。
按理来说,生产上的数据已经是错误的了,而且是一个“利息金额扩大十倍”的明显的错误,如果主动去做了数据验证,应该能被发现才对。
那为什么做了生产验证,却没有发现问题呢?
因为当时存量配置有三条,我提供了 3 个 SQL,其中有一个是算对了的。
每一条存量配置都对应着大量的利息数据,而算对了的这个对应的数据更多,在比例上超过 60%。
我进行生产验证的时候,在大量的利息计提数据中随机抽选了两条,选中的这两条,恰好都是正确的 SQL 对应的数据。
所以我发现符合预期,得出了生产验证通过的结论。
站在这个节点,回顾整个事件,这个时候应该是最有可能发现问题的时候。
但是没发现。
根本原因是验证方案不严谨,玄学原因是运气不站在我这边。
怎么暴露的?
你想想,这种业务参数配置错误的问题你能通过什么监控规则监控到吗?
其实很难的。
我们一般来说做技术层面的监控,都是监控程序是否按照预期正常运行。比如在计算的过程中出现异常,那我们是可以监控到的。
但是在这种只是参与计算的值不对,但是能正常计算出一个值的情况,并不会报错。
这种问题通过技术手段很难监控到。如果硬要去做监控,肯定是能做的,比如从异常浮动的维度、横向数据对比的维度,但是配套的开发成本又上去了。
我是怎么发现这个问题的呢?
也是纯粹的运气。
是一个周五的晚上,我做另外的一个和本问题毫无关系的场景下的数据验证的时候,偶然间看到了一笔数据的金额和前几天比,明显大了很多。
这是不符合业务规律的。
然后进一步跟踪,最终定位到了前面的问题 SQL。这个时候距离这个 SQL 上线,已经过去了三天,已经产生了一批错误数据了。
如果我没有偶然间看到这个问题数据,那么这个问题会在什么环节暴露呢?
就是在业务使用这个数据做核对的时候。
那个时候整个问题的性质就变了。不仅是处理时间来不来得及的问题了,而是这个问题是由“开发自主发现”还是由“外部反馈发现”这两个完全不同的性质了。
一般来说,不管是什么问题,先抛开严重程度,只要是开发自主发现的,都能一定程度上让事情变得不那么难堪。
所以我们才一度强调“可监控”的重要性。
随后,我联系了业务,反馈了这个情况。他表示在他下次使用这批数据之前,把数据修复好就行。大概一个月后,他会用到这批数据。
这样,我有接近一个月的时间来处理这个问题,防止问题扩大化。
时间非常充足,站在这个角度,我运气还不错的。
问题已经暴露出来了,随后就是制定针对这批错误数据的修复方案了。
修复方案就和业务场景相关了,属于多个业务场景叠加在一起,所以修复方案其实是比较复杂的,涉及到“修数”和“补数”,没有展开描述的必要了。
只是想简单提一句,这个修复方案是我利用周末的时间想出来的,很多细节问题我都需要考虑到,甚至在心里写了一遍伪代码。
确实是浪费了周末的时间,但是这是为自己的错误买单,半点不怨别人,就是活该。
而对于参与后续方案讨论的同事来说,在这件事情上付出的时间,才是属于无妄之灾。
这就是整个事情的过程,一个小数点引发的血案。
再回首
现在整个事情的全貌都在你眼前了,你得到了什么经验教训?
因为手抖了,写错了一位小数,这确实是直接原因,所以是想着下次再处理这种数据的时候,更加小心一点吗?
我觉得不是这样的。
我得到的经验教训就是我的标题:开发人员,千万不要去碰那该死的业务参数!
如果在最开始需求评审会,我们讨论到存量数据的时候。
业务说:这次需求上线的时候,你按照 11 位小数重新算好,然后写 SQL 更新一下就行。
我说:不行,这个属于是业务参数,我不能去动。上线完成后,就具备这个功能了,你可以通过页面配置去修改。
我知道他们修改业务参数的流程,很长很复杂。
首先业务需要发起一个参数变更的 OA 流程,然后走到他的部门负责人审批。
业务部门负责人审批完成后,会到具体负责业务参数配置的人员手里,还需要该人员对应的部门负责人审核。
审核完成后有权限的人员才会去修改这个业务参数,而这个参数的修改,在对应的系统功能上还有两级甚至三级审核。
整个完成之后发起 OA 的人员还需要进行变更确认,看看页面上是否是自己想要的配置。
这一套流程走下来,你觉得还会出错吗?
很难出错了。
你可以批判这个流程过于臃肿,但是你最终总是会认识到,这个流程其实是在保护打工人。
我知道他流程比较复杂,而我写个 SQL 几乎是没有成本的,但是这是在 SQL 正确的前提下。
如果当时不答应通过 SQL 的方式帮他处理存量数据,他其实有更加正规的流程去处理这些数据,而且不会出错。
事后我们复盘的时候,也有同事私下向我提出了这个的问题:为什么不走 OA 流程去调整这个参数?
另外,关于流程,我给你举一个程序员方面的例子。
一个核心开发人员拥有线上数据库的操作权限,我们先假设这个人绝对忠诚、绝对可以信赖、绝对恪尽职守、绝对不会删库跑路。
某一天,他收到一个预警信息,经过排查发现需要去修改数据库里面某个数据的状态,他直接就去修改了。
这个操作非常常见,特别是在小公司或者在一些在快速发展阶段的公司。
后来这个公司成长起来了,开始更加注重操作风险了,回收了所有人员的数据库权限,以前的事儿既往不咎,以后想要修改数据库数据,必须要发起一个审批流程,经过层层审批之后才能执行。
这个流程和“直接去修改”这个动作比起来,就重了无数倍了。
站在程序员的角度,前几年都是可以直接操作生产数据,突然这个制度出来了,极大的影响了之前的开发惯性。所以刚刚开始执行的时候,你可能会骂一句:xxx。
但是长远来看,这个流程其实是在保护你。
当你有数据库权限的时候,操作对了,没有人会夸你。操作错了,你就是罪魁祸首。
有了一个审批流程,在加重了操作成本的同时,也降低了错误成本。
处理问题的时长可能增加了,对于问题处理的敏捷度可能降低了,但是站在公司的角度,随着公司的发展“稳定”才是永恒的主旋律,在稳定面前,敏捷度反而是可以牺牲的。
歪师傅在第一家公司业务野蛮发展的时代,曾经就有这样的权限,那个时候刚刚参加工作两年多的时间,觉得事情就应该是这样的,这样才是正确的,可以足够敏捷,足够迅速的处理问题。
后来权限回收了,当时我也在私底下骂骂咧咧了几句。
再回来,随着经验和在职场上见过得事儿越来越多,才渐渐认识到:蛮荒时代确实出英雄,但是我没有把握好机会成为英雄。蛮荒时代之后的流程规范,规章制度其实是在保护那批没有成为英雄的人,其中就有我。
最后,给你,也给我自己一个忠告:开发人员,你最好要知道你数据库里面每一个业务参数背后的业务含义,但是千万不要去碰那该死的业务参数。也轮不到你碰,该碰的人会在正确的流程下去碰。
无论什么时候,心中都要绷着这根弦。
来源:juejin.cn/post/7435928632081252378
彻底理解import和require区别和用法
前言
在真实工作中,估计import
和require
大家经常见到,如果做前端业务代码,那么import
更是随处可见了。但我们都是直接去使用,但是这两种方式的区别是什么呢?应用场景有什么区别呢?
大部分能说出来import
是ES6
规范,而require
是CommonJS
规范,然后面试官深入问你两者编译规则有啥不一样?然后就不知道了
本文一次性对import
和require
的模块基本概念
、编译规则
、基本用法差异
、生态支持
和性能对比
等5
个方面一次理清总结好,下次遇到这种问题直接举一反三。
一、模块基本概念
require
: 是CommonJS
模块规范,主要应用于Node.js
环境。
import
:是ES6
模块规范,主要应用于现代浏览器和现代js
开发(适用于例如各种前端框架)。
二、编译规则
require
:
require
执行时会把导入的模块进行缓存,下次再调用会返回同一个实例。
在CommonJS
模块规范中,require
默认是同步的。当我们在某个模块中使用require
调用时,会等待调用完成才接着往下执行,如下例子所示。
模块A
代码
console.log('我是模块A的1...');
const moduleB = require('./myModuleB');
console.log('我是模块A的2');
模块B
代码
console.log('我是模块B...');
打印顺序,会按顺序同步执行
// 我是模块A的1...
// 我是模块B...
// 我是模块A的2...
注意:
require
并非绝对是同步执行,例如在Webpack
中能使用require.ensure
来进行异步加载模块。
import
:
在ES6
模块规范中,import
默认是静态编译的,也就是在编译过程就已经确认了导入的模块是啥,因此默认
是同步的。import
有引用提升置顶效果,也就是放在何处都会默认在最前面。
但是...., 通过import()
动态引入是异步的哦,并且是在执行中加载的。
import()
在真实业务中是很常见的,例如路由组件的懒加载component: () => import('@/components/dutest.vue')
和动态组件const MyTest = await import('@/components/MyTest.vue');
等等,import()
执行返回的是一个 Promise
,所以经常会配合async/await
一起用。
三、基本用法差异
require
:
一般不直接用于前端框架,是用于 Node.js
环境和一些前端构建工具(例如:Webpack
)中
1. 导入模块(第三方库)
在Node.js
中经常要导入各种模块,用require
可以导入模块是最常见的。例如导入一个os
模块
const os = require('os');
// 使用
os.platform()
2. 导入本地写好的模块
假设我本地项目有一个名为 utils.js
的本地文件,文件里面导出一个add
函数
module.exports = {
add: (a, b) => a + b,
};
在其它文件中导入并使用上面的模块
const { add } = require('../test/utils');
// 使用
add(2, 3);
import
:
一般都是应用于现在浏览器和各种主流前端框架(例如:Vue\react
)
1. 静态引入(项目中最常用)
这种情况一般适用于确定的模块关系,是在编译时解析
<script setup>
import { ref } from 'vue';
import test from '@/components/test.vue';
</script>
2. 动态引入
其实就是使用import()
函数去返回一个 Promise
,在Promise
回调函数里面处理加载相关,例如路由的懒加载。
{
path: '/',
name: 'test',
component: () => import('@/components/dutest.vue')
},
或者动态引入一些文件(或者本地的JSON
文件)
<script setup>
const MyTest = await import('@/components/MyTest.vue');
</script>
四、生态支持
require
:
Node.js14
之前是默认模块系统。目前的浏览器基本是不原生支持 CommonJS
,都是需要通过构建工具(如 Webpack
)转换才行。并且虽然目前市面上CommonJS
依然广泛使用,但基本都是比较老的库,感觉被逐渐过渡了。
import
:
import
是ES6
规范,并且Node.js
在Node.js12
开始支持ES6
,Node.js14
之后是默认选项。目前现代浏览器和主流的框架(Vue、React
)都支持原生ES6
,大多数现代库也是,因此import
是未来主流。
五、性能对比
ES6
支持 Tree Shaking
摇树优化,因此可以更好地去除一些没用的代码,能很好减小打包体积。
所以import
有更好的性能。
import()
能动态导入模块性能更好,而require
不支持动态导入。
小结
对比下来发现,import
不但有更好性能,而且还是Node.js14
之后的默认,会是主流趋势。
至此我感觉足够能举一反三了,如有哪里写的不对或者有更好建议欢迎大佬指点一二啊。
来源:juejin.cn/post/7425135423145394213
深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘
深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘
近年来,面部识别和 AR 特效技术的普及让我们在日常应用中越来越多地接触到有趣的互动体验。而基于 facemesh.js 的二次开发框架——effet.js,则为开发者提供了一种简单而强大的方式来实现面部特效和识别功能。今天,我们将通过 effet.js 的项目结构,深入探讨其运行原理,看看它是如何利用前沿技术来实现流畅的人脸检测与交互的。
1. effet.js 的整体架构
effet.js 的项目结构采用模块化设计,通过明确的目录划分,将各项功能进行清晰地组织和封装。我们可以将项目大致分为以下几个部分:
- components:主要包括内部初始化数据、公共枚举以及管理当前 DOM 实例的逻辑。
- core:核心模块,包含动作处理、数据库交互、DOM 操作等多项功能,是整个框架的关键部分。
- styles:存放框架的样式文件,用于定义人脸特效的视觉表现。
- util:各种通用工具函数,包括摄像头操作、条件检测、绘制等常用的辅助功能。
- index.js:整个项目的入口文件,负责初始化和启动框架。
接下来,我们将详细介绍这些模块的作用及其在 effet.js 运行过程中的角色。
2. components 组件模块
components
目录主要用于存放框架的公共组件和初始化数据。
- AppState.ts:管理内部的初始化数据,包括摄像头、DOM 元素等基本信息。
- FaceManager.ts:用于管理当前 DOM 单例,这个类的作用是确保在处理消息替换时,始终对同一 DOM 元素进行操作,避免出现不必要的内存占用和资源浪费。
这种设计让 effet.js 在处理多次人脸检测和特效应用时能够高效、稳定地管理 DOM 元素。
3. core 核心模块
core
目录是 effet.js 的核心逻辑所在,涵盖了以下几个重要部分:
- action:动作处理模块,包含人脸添加、登录检测、睡眠检测和打卡等功能。例如,
addFace/index.js
负责处理用户人脸添加的逻辑,而checkLogin/index.js
则用于人脸登录的检测。 - before:动作前的预处理模块。每个动作在执行前,可能需要一些额外的检查或准备工作。例如,
checkLogin/index.js
用于处理登录前的检查逻辑。 - db:数据库模块,
db.js
负责使用 IndexedDB 来存储和缓存用户数据、模型信息等。这种设计让 effet.js 可以离线运行,提升了用户体验。 - defaultAssign:默认配置和参数分配模块,
assign.js
用于为框架中的各个组件提供初始参数和默认值,确保框架在各种环境下均能正常运行。 - dom:DOM 操作模块,包含创建和管理人脸相关 DOM 元素的逻辑,例如
createFaceElements.js
用于动态创建人脸特效所需的 DOM 元素。 - log:用于屏蔽插件相关的内部警告信息,
log.js
确保了控制台的整洁,方便开发人员进行调试。
核心模块的分层设计使得每个功能都具有独立性和可维护性,这对于复杂交互和特效的实现尤为重要。
代码示例:人脸添加动作
以下是处理人脸添加动作的代码示例:
import { addFace } from './core/action/faceAction';
// 执行人脸添加逻辑
addFace().then(() => {
console.log('人脸添加成功');
}).catch(error => {
console.error('人脸添加失败:', error);
});
上述代码展示了如何调用核心模块中的人脸添加逻辑来实现用户的人脸注册功能。
4. styles 样式模块
styles
目录包含所有与视觉表现相关的文件,用于定义人脸特效的外观。
- template:存放不同功能的样式模板,如
addFace
和checkLogin
等模块中的index.css
定义了对应特效的样式。 - faceColor.js:用于处理与人脸特效颜色相关的逻辑,让开发者可以根据需求自定义特效的颜色效果。
- root.css:全局样式文件,定义了一些基础样式和布局。
样式模块确保了特效在浏览器中展示时具有一致性和视觉吸引力。通过将样式与逻辑分离,开发者可以更方便地对特效的外观进行调整。
图片示例:人脸添加样式
5. util 通用工具模块
util
目录包含了多个工具函数,用于简化常见的任务,例如摄像头操作、距离计算和人脸网格处理。
- cameraAccessUtils.js 和 cameraUtils.js:用于处理摄像头的访问和操作,如获取摄像头权限、切换摄像头等。
- faceMesh.js:负责处理人脸网格的相关逻辑,例如通过 facemesh 模型获取人脸特征点。
- distanceUtils.js 和 drawingUtils.js:用于进行距离计算和绘图操作,这些工具函数在渲染特效时被频繁使用。
这些工具函数的存在,让开发者可以专注于高层次的功能开发,而不必担心底层的细节实现。
代码示例:摄像头访问工具
import { requestCameraAccess } from './util/cameraAccessUtils';
// 请求摄像头权限
requestCameraAccess().then(stream => {
console.log('摄像头权限已获取');
// 使用摄像头流进行进一步处理
}).catch(error => {
console.error('无法获取摄像头权限:', error);
});
6. 使用 IndexedDB 的缓存机制
effet.js 通过 core/db/db.js
使用 IndexedDB 来缓存模型和其他依赖资源,从而减少每次加载的时间。这种设计可以显著提升用户体验,尤其是对于网络环境不稳定的用户。
IndexedDB 是浏览器内置的一个低级 API,它允许我们在用户设备上存储大量结构化数据。在 effet.js 中,我们利用 IndexedDB 缓存 facemesh 模型和用户的面部数据,确保在用户首次加载模型后,后续访问时能够直接从缓存中读取数据,而无需重新下载模型。这不仅提升了加载速度,还降低了对网络的依赖。
代码示例:使用 IndexedDB 缓存模型
// 缓存 facemesh 模型到 IndexedDB
async function cacheModel(modelData) {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readwrite');
const store = transaction.objectStore('models');
store.put(modelData, 'facemeshModel');
}
// 从 IndexedDB 加载模型
async function loadModelFromCache() {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readonly');
const store = transaction.objectStore('models');
return store.get('facemeshModel');
}
function openIndexedDB(dbName, version) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
通过这样的缓存机制,用户在第一次访问时可能需要加载较长时间,但后续的访问速度会大大提升,从而提供更流畅的用户体验。
7. 核心模块中的预处理逻辑
在 core/before
目录下,包含了许多预处理逻辑,这些逻辑在每个动作执行之前运行,以确保后续的动作能够顺利进行。
- checkLogin/index.js:负责在执行人脸登录之前检查用户的身份信息,例如判断用户是否已经注册或存在于数据库中。
- faceBefore.js:是所有预处理逻辑的入口,通过调用不同的预处理函数,确保每个动作的执行环境和条件都已满足。
这种预处理机制有效地提高了系统的稳定性和安全性。例如,在进行人脸登录时,如果用户的数据尚未注册或缓存不完整,系统将通过预处理逻辑提前捕获这些问题并进行相应的处理。
代码示例:登录前的预处理
import { checkLogin } from './core/before/faceBefore';
// 执行登录前的检查逻辑
checkLogin().then(() => {
console.log('预处理完成,准备执行登录动作');
}).catch(error => {
console.error('登录预处理失败:', error);
});
8. DOM 操作和特效渲染
effet.js 中的 DOM 操作模块负责创建和管理人脸特效相关的 DOM 元素。通过对 DOM 的操作,我们可以将各种特效应用到用户的人脸上,并根据用户面部的变化来实时调整特效的位置和形状。
- createFaceElements.js:用于动态创建用于渲染特效的 DOM 元素。这些元素包括虚拟的眼镜、面具等,通过将它们附加到特定的人脸特征点,可以实现各种视觉特效。
- defaultElement.js:提供了一些默认的 DOM 元素配置,如特效的初始位置、大小和样式等。
代码示例:创建人脸特效元素
import { createFaceElements } from './core/dom/createFaceElements';
// 创建用于渲染特效的 DOM 元素
createFaceElements().then(elements => {
console.log('特效元素创建成功', elements);
}).catch(error => {
console.error('特效元素创建失败:', error);
});
通过将特效元素与人脸特征点绑定,我们可以实现一些有趣的交互。例如,当用户张嘴或眨眼时,系统可以检测到这些动作并触发相应的视觉反馈,从而提升用户体验的互动性。
9. 样式与用户体验
样式模块不仅仅是为了让特效看起来美观,更是为了确保其与用户的操作无缝对接。styles/template
目录下的样式模板被精心设计,以适应不同类型的设备和显示环境。
例如,addFace/index.css
和 checkLogin/index.css
分别定义了人脸添加和登录检测的样式,通过这些样式文件,开发者可以轻松地实现具有一致性且专业的用户界面。
图片示例:多种特效风格
这种模块化的样式管理方式让我们可以快速调整不同特效的外观,同时确保代码的可读性和可维护性。在不断的版本迭代中,样式模块可以独立于逻辑模块进行修改,极大地提高了项目的可扩展性。
10. effet.js 的初始化流程
index.js
作为项目的主要入口,负责初始化 effet.js 的所有模块。在初始化过程中,系统会调用核心模块、样式模块和工具函数,以确保整个框架能够无缝启动。
- initializeEffet:这是一个主函数,负责加载配置、初始化摄像头、检测用户设备是否满足要求,并调用各个核心模块来启动面部检测和特效渲染。
- FaceManager:用于管理初始化后的 DOM 元素,确保在不同的特效之间切换时,DOM 操作能够始终保持一致。
代码示例:框架初始化
import { initializeEffet } from './core/index';
// 初始化 effet.js 框架
initializeEffet().then(() => {
console.log('effet.js 初始化完成');
}).catch(error => {
console.error('初始化失败:', error);
});
通过这样的初始化流程,我们可以确保框架的各个部分能够正确协同工作,从而为用户提供稳定且高质量的体验。
11. 开发与优化的挑战
effet.js 在开发过程中遇到了许多挑战,例如:
- 模型加载时间长:由于 facemesh 模型文件较大,我们使用 IndexedDB 来缓存模型,减少用户每次访问的加载时间。
- 资源丢包与网络不稳定:为了解决网络环境不稳定的问题,我们采用了离线缓存策略,并通过优化加载顺序和减少请求次数来提升加载速度。
- 性能优化:为了保证在中低端设备上的流畅运行,我们对人脸检测和特效渲染进行了大量优化,包括减少计算开销、利用 WebGL 加速等。
这些挑战促使我们不断改进框架的架构和实现方式,以确保 effet.js 在不同的设备和网络环境下都能表现出色。
结语
effet.js 是一个旨在降低面部识别和特效开发门槛的框架,通过模块化的项目结构封装复杂的底层逻辑,让开发者可以专注于创造有趣的互动体验。通过组件、核心模块、样式、工具函数以及缓存机制的有机结合,effet.js 为开发者提供了强大的基础设施来构建各种人脸交互应用。
通过这篇文章,我们展示了 effet.js 的整体架构、人脸检测与特征点定位、特效渲染、交互逻辑实现以及优化挑战,并结合代码示例和图片示例来帮助你更好地理解其运行原理。希望你能够从中获得启发,创造更多有趣的应用!
如果你有任何问题或者想要进一步了解它的应用,欢迎在评论区留言讨论!
这篇博客旨在详细介绍 effet.js 的运行原理和模块化设计,帮助开发者深入了解其工作机制。在未来的开发中,我们期待更多的人参与到这个项目中来,共同探索面部识别和特效技术的更多可能性。
更多资源
- 官方文档:faceeffet.com
- GitHub 仓库:github.com/typsusan/ef…
- Gitee 仓库:gitee.com/susantyp/ef…
来源:juejin.cn/post/7433805918494916618
仿拼多多领红包、金额数字滚动如何实现?
拼多多现金大转盘领取红包后,小数部分有一个数字移动的效果,这个效果怎么做呢?
本文我会告诉你数字移动的原理,并用 React 实现一个 Demo,效果如下:
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
滚动原理
不难想到,数字移动,实质是一串数字列表,在容器内部向上移动。下图就展示了数字从 0 变为 2,又从 2 变为 4 的过程:
但是,金额数字比较特殊,它是可以进位的。举例来说,39.5 变为 39.9 时,小数部分由 5 到 9 需要向上移动;39.9 变为 40.2 时,小数部分由 9 变到 2 时也需要向上移动。
为了做到这个效果,我们需要每次滚动结束之后,重新设置一整串数字。
同样是从 0 变为 2,又从 2 变为 4。下图不同的是,数字变为 2 时,它下方的数字变为了 3、4、5、6、7、8、9、0、1;数字变为 4 时,它下方的数字变为了 5、6、7、8、9、0、1、2、3。
关键布局
了解原理后,我们开始写元素布局。关键布局有 2 个元素:
- 选择框,它可以确认下一个将要变成的数字,我们用它来模拟领取红包之后、金额变化的情况。
- 数字盒子,它包括三部分,带 overflow: hidden 的外层盒子,包裹数字并向上滚动的内层盒子,以及一个个数字。
点击查看代码
const App = function () {
const [options] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const [nums, setNums] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
return (
<main>
<div className="select-box">
<span>改变数字:</span>
<select>
{
options.map(v => (
<option key={v}>{v}</option>
))
}
</select>
</div>
<div className="num-box">
<div>
{
nums.map(v => (
<div className="num" key={v}>{v}</div>
))
}
</div>
</div>
</main>
)
};
关键逻辑
数字移动的关键逻辑有 3 个:
- 重置数字数组
- 计算移动距离
- 开启关闭动画
重置数字数组
你之前已经知道,如果数组首位是 2,它下面的数字就是 3、4、5、6、7、8、9、0、1。要获取完整的数组,我们可以分为两个步骤:
- 首先,数组首位背后的数字依次加 1,这样数字就变为了 3、4、5、6、7、8、9、10、11;
- 然后,所有大于 9 的数字都减去 10,这样数字就变为了 3、4、5、6、7、8、9、0、1。
点击查看代码
const getNewNums = (next) => {
const newNums = []
for (let i = next; i < next + 10; i++) {
const item = i > 9 ? (i - 10) : i
newNums.push(item)
}
return newNums
}
计算移动距离
你可以用 current 表示当前的数字,next 表示需要变成的数字。计算移动距离时,需要分两种情况考虑:
- next 大于 current 时,只需要移动
next - current
个数字即可; - next 小于 current 时,需要先移动
10 - next
个数字,再移动 current 个数字即可。
点击查看代码
const calculateDistance = (current, next) => {
const height = 40
let diff = next - current
if (next < current) {
diff = 10 - current + next
}
return -(diff * height)
}
开启关闭动画
不难想到,我们数字移动的动画是使用 translateY 和 transition 实现。当数字移动时,我们把 translateY 设置为 calculateDistance 的结果;当移动结束、重置数组时,我们需要把 translateY 设置为 0。
整个过程中,如果我们一直开启动画,效果会是数字先向上移动,再向下移动,这并不符合预期。
因此,我们需要在数字开始移动时开启动画,数字结束移动后、重置数组前关闭动画。
点击查看代码
const App = function () {
// ... 省略
const numBoxRef = useRef()
const onChange = (e) => {
// 开启动画
numBoxRef.current.style.transition = `all 1s`
// ... 省略
}
const onTransitionEnd = () => {
// 关闭动画
numBoxRef.current.style.transition = ''
// ... 省略
}
return (
<main>{/* ... 省略 */}</main>
)
};
完整代码
总结
本文介绍了类似拼多多的金额数字滚动如何实现,其中有三个关键点,分别是重置数字数组、计算移动距离和开启关闭动画。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
来源:juejin.cn/post/7430861008753688576
calc-size: 一个解决浏览器11年的 bug的属性终于上线了
我们在平常写动画的时候,经常会遇到元素的高度 height:auto或者 100% 时,无法触发过渡动画效果。
这是浏览器一直存在的问题,这个问题最早可以追溯到距今2013年,可以说是由来已久的问题了。
问题复现
我们先来复现一遍这个问题。
当我们将盒子的高度从 0
变成 auto
或者 100%
的时候, 盒子是没有过度动画的。显示得很生硬。
不过我们可以使用其他的方式去使得这个过渡动画生效,方法有很多,这里就不过多追溯了。
calc-size 插值计算属性
calc-size
属性是一个最最新提出的属性, 和 calc
类似,都是可以计算的。现阶段还在一个草案阶段,但是浏览器已经有支持的计划了,预计在 chrome 129 版本就正式发布了。
如果要在浏览器中体验这个属性,可以在 chrome://flags/
中开启 Experimental Web Platform features
进行体验
基础语法
<calc-size()> = calc-size( <calc-size-basis>, <calc-sum>? )
- calc-size-basis: 可以是
px
、auto
、max-content
、percent
等等
- calc-sum:表示只可以进行 css 单位进行 相加、相减 操作
使用示例
通过使用 calc-size
属性计算高度的插值过程,这样就可以实现高度从 0 到 300px
的高度过渡变化。
interpolate-size 全局属性
interpolate-size
可以让我们在根元素上设置插值计算的规则,这样针对整个页面都会生效。
interpolate-size
有两个值,一个是 allow-keywords
所有关键字,一个是仅限数字 numeric-only
。
numeric-only
设置了这个属性之后,如果插值的属性值不是数字的话,就不会产生过渡的效果
只有设置了数字,过渡才会生效。
allow-keywords
设置了这个属性,只要是合法的属性值,都会插值计算,从而都会产生过渡效果。
小结
相信再过上一两年, calc-size支持计划,我们就可以在浏览器中使用 cacl-size
插值计算属性了。到时候就不需要再用 hack
的方法处理过渡效果。
如果这篇文章对你有帮助,欢迎点赞、关注➕、转发 ✔ !
来源:juejin.cn/post/7395385447294271526
qs.js库的使用
用于url参数转化:parse和stringify的js库
import qs from 'qs'
qs.parse('a=b&c=d'); => 转化为{ a: 'b', c: 'd' }
qs.stringify({a: 'b', c: 'd'}) => 转化为‘a=b&c=d’
qs.parse
qs.parse 方法可以把一段格式化的字符串转换为对象格式
let url = 'http://item.taobao.com/item.htm?a=1&b=2&c=&d=xxx&e';
let data = qs.parse(url.split('?')[1]);
// data的结果是
{
a: 1,
b: 2,
c: '',
d: xxx,
e: ''
}
qs.stringify
基本用法
qs.stringify 则和 qs.parse 相反,是把一个参数对象格式化为一个字符串。
let params = { c: 'b', a: 'd' };
qs.stringify(params)
// 结果是
'c=b&a=d'
排序
甚至可以对格式化后的参数进行排序
qs.stringify(params, (a,b) => a.localeCompare(b))
// 结果是
'a=b&c=d'
let params = { c: 'z', a: 'd' };
qs.stringify(params, {
sort: (a, b) => a.localeCompare(b)
});
// 结果是
'a=d&c=z'
指定数组编码格式
let params = [1, 2, 3];
// indices(默认)
qs.stringify({a: params}, {
arrayFormat: 'indices'
})
// 结果是
'a[0]=1&a[1]=2&a[2]=3'
// brackets
qs.stringify({a: params}, {
arrayFormat: 'brackets'
})
// 结果是
'a[]=1&a[]=2&a[]=3'
// repeat
qs.stringify({a: params}, {
arrayFormat: 'repeat'
})
// 结果是
'a=1&a=2&a=3'
处理json格式的参数
在默认情况下,json格式的参数会用 [ ] 方式编码
let json = { a: { b: { c: 'd', e: 'f' } } };
qs.stringify(json);
//结果 'a[b][c]=d&a[b][e]=f'
但是某些服务端框架,并不能很好的处理这种格式,所以需要转为下面的格式
qs.stringify(json, {allowDots: true});
//结果 ‘a.b.c=d&a.b.e=f’
来源:juejin.cn/post/7431999633071030283
CSS 技巧:如何让 div 完美填充 td 高度
引言
一天哈比比突然冒出一个毫无理头的一个问题:

本文就该问题进行展开...
一天哈比比突然冒出一个毫无理头的一个问题:
本文就该问题进行展开...
一、需求说明
大致需求如下, 当然这里做了些简化
有如下初始代码:
- 一个自适应的表格
- 每个单元格的宽度固定
200px
- 每个单元格高度则是自适应
- 每个单元格内是一个
div
标签, div
标签内包裹了一段文本, 文本内容不定
下面是初始代码(为了方便演示和美观, 代码中还加了些背景色、边距、圆角, 这些都是可以忽略):
<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}
td {
background: #ffccc7;
}
table, tr, td {
padding: 12px;
border-radius: 4px;
}
td > div {
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>
上面代码的整体效果如下:

上面是哈比比目前的现状, 然后需求就是希望, 黄色部分也就是 div
标签能够高度撑满单元格(td
), 也就是如下图所示:

大致需求如下, 当然这里做了些简化
有如下初始代码:
- 一个自适应的表格
- 每个单元格的宽度固定
200px
- 每个单元格高度则是自适应
- 每个单元格内是一个
div
标签,div
标签内包裹了一段文本, 文本内容不定
下面是初始代码(为了方便演示和美观, 代码中还加了些背景色、边距、圆角, 这些都是可以忽略):
<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}
td {
background: #ffccc7;
}
table, tr, td {
padding: 12px;
border-radius: 4px;
}
td > div {
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>
上面代码的整体效果如下:
上面是哈比比目前的现状, 然后需求就是希望, 黄色部分也就是 div
标签能够高度撑满单元格(td
), 也就是如下图所示:
二、关键问题
这里我第一反应就是, 既然 td
高度是对的(自适应)的那么 div
高度直接设置 100%
不就好了吗? 事实是这样的吗? 我们可以试下:
...
实际效果肯定是没有用的, 要不然也就不会有这篇文章了 🐶🐶🐶

主要问题: 在 CSS
中如果父元素没有一个明确的高度, 子元素设置 100%
是无法生效的, 至于为啥就不能生效呢, 因为如果可以, 那么必然会进入死循环这里可以参考张鑫旭大大的文章《从 height:100% 不支持聊聊 CSS 中的 "死循环"》
这里我第一反应就是, 既然 td
高度是对的(自适应)的那么 div
高度直接设置 100%
不就好了吗? 事实是这样的吗? 我们可以试下:
...
实际效果肯定是没有用的, 要不然也就不会有这篇文章了 🐶🐶🐶
主要问题: 在 CSS
中如果父元素没有一个明确的高度, 子元素设置 100%
是无法生效的, 至于为啥就不能生效呢, 因为如果可以, 那么必然会进入死循环这里可以参考张鑫旭大大的文章《从 height:100% 不支持聊聊 CSS 中的 "死循环"》
三、方案一(定位)
通过定位来实现, 也是哈比比最初采用的一个方案:
td
设置相对定位即: position: relative;
td
下的子元素通过相对定位(position: absolute;
)撑满
....
整体效果如下:

上面代码其实我并没有给所有 td
中的 div
设置 position: absolute;
目的是为了留一个内容最多的块, 来将 tr
、 td
撑开, 如果不这么做就会出现下面这种情况:

所以, 严格来说该方案是不行的, 但是可能哈比比情况比较特殊, 他只有空值和有内容两种情况, 所以他完全可以通过判断内容是否为空来设置 position: absolute;
即可
通过定位来实现, 也是哈比比最初采用的一个方案:
td
设置相对定位即:position: relative;
td
下的子元素通过相对定位(position: absolute;
)撑满
....
整体效果如下:
上面代码其实我并没有给所有 td
中的 div
设置 position: absolute;
目的是为了留一个内容最多的块, 来将 tr
、 td
撑开, 如果不这么做就会出现下面这种情况:
所以, 严格来说该方案是不行的, 但是可能哈比比情况比较特殊, 他只有空值和有内容两种情况, 所以他完全可以通过判断内容是否为空来设置 position: absolute;
即可
四、方案二(递归设置 height 100%)
第二个方案就是给 table
、tr
、td
设置一个明确的高度即 100%
, 这样的话 td
中的子元素 div
再设置高度 100%
就可以生效了
效果如下:

上面第一个单元格高度其实还是有点问题, 目前也没找到相关研究可以结束这个现象, 要想达到我们要的效果解决办法有两个:
- 移除代码中所有
padding
, 有关代码和效果图如下:

- 修改
td
中 div
的 box-sizing
属性为 border-box
, 有关代码和效果图如下:
第二个方案就是给 table
、tr
、td
设置一个明确的高度即 100%
, 这样的话 td
中的子元素 div
再设置高度 100%
就可以生效了
效果如下:
上面第一个单元格高度其实还是有点问题, 目前也没找到相关研究可以结束这个现象, 要想达到我们要的效果解决办法有两个:
- 移除代码中所有
padding
, 有关代码和效果图如下:
- 修改
td
中div
的box-sizing
属性为border-box
, 有关代码和效果图如下:
五、方案三(利用 td 自增加特效, 推荐)
方案三是比较推荐的做法, 其利用了 td
自增加的一个特效, 那么何谓自增加呢? 假设我们给 td
设置可一个高度 1px
但是呢它实际高度实际上是会根据 tr
的高度进行自适应(自动增长), 那么在这种情况下我们给 td
下子元素 div
设置高度 100%
则会奏效, 因为这时的 td
高度是明确的
<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}
td {
height: 1px; /* 关键代码 */
background: #ffccc7;
}
table, tr, td {
padding: 12px;
border-radius: 4px;
}
td > div {
height: 100%; /* 关键代码 */
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>
效果如下:
六、补充: td 下 div 内容顶对齐
几天后, 哈比比又来找我了 🐶🐶🐶
这次需求就比较简单了, 就是 td
中默认情况下子元素(p
)都是居中呈现的, 现想要的就是能否居上(置顶)展示
这里初始代码和上面是一样的:
<table>
<tr>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
<td width="400">
<div>
路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面路由器管理页面
div>
td>
tr>
table>
<style>
table {
background: #f5f5f5;
}
td {
background: #ffccc7;
}
table, tr, td {
padding: 12px;
border-radius: 4px;
}
td > div {
padding: 12px;
border-radius: 4px;
background: #f4ffb8;
}
style>
默认效果就是 div
都居中展示:
这里我第一反应是用 vertical-align
但是该属性在很多人印象中只针对 行内元素
(或文本
)才能生效, 但这里是 div
是 块元素
所以哈比比自然就忽略了该 vertical-align
属性
但实际上如果查阅文档会发现 vertical-align
实际用途有两个:
- 用来指定行内元素(
inline
)的垂直对齐方式 - 表格单元格(
table-cell
)元素的垂直对齐方式
所以这个问题就简单了, 一行 CSS
就解决了:
完美实现(最终效果):
七、参考
作者:墨渊君
来源:juejin.cn/post/7436027021057884172
来源:juejin.cn/post/7436027021057884172
终于知道公钥、私钥、对称、非对称加密是什么了
有接入过第三方服务的同学应该都接触过公钥、私钥这类的说明,尤其是一些对参数要求验证的服务,比如支付类的。
通常对于高保密要求的参数都会有加密要求,这时候,如果你之前不了解加密算法,就很容易被公钥、私钥的使用给绕迷糊了。有时候虽然接口都调通了,但是还是一头雾水,这就通了?
那接下来我们就来解开这团迷雾。咱们只讲逻辑和一部分逻辑,不讲数学算法,因为数学这块儿我也不是很懂。
加密算法在大类上分为对称机密和非对称加密,都用来加密,只不过使用场景、性能、安全性方面有些不同。
首先说什么是加密呢?
加密就是通过一种方式将一个显而易见的东西变成难以理解的东西。
比如这儿有个字符串“我真棒”,这三个字儿要摆在你面前不是一眼就知道我很棒了吗。
但是我要给你看这样一串东西呢?你还能一下子知道是什么意思吗?
232 10 5,33 50 12,109 45 1
其实这还是那三个字,只不过是经过加密的密文,只有知道了加密的方法才能还原出来,也就是解密。
加密的过程可能是这样的:
- 首先买一本叫做《人间清醒》的书;
- 然后按照逗号分隔,每一个逗号分隔开的表示一个字;
- 然后每一组是三个数字,第一个数字表示所在页,第二个数字表示所在行,第三个数字表示所在列(第几个字),所以
232 10 5
,表示第232页,第10行,第5个字。
还有,比如我之前有个温州的同事,他打电话的时候对于我们来说就是语音加密了,能听见他的声音,但是说的是什么内容一个字也听不出来。这也可以理解为一种加密,把普通话的发音转换成温州方言的发音。
对称加密
对称加密中所说的对称是对加密和解密而言的,也就是加密和解密的过程使用相同的密钥。
我们经常用到的落库加密、文件加密都可以使用对称加密的方式。
目前最常用也是最安全的对称加密算法是 AES,AES 还分为 AES-128、AES-192和AES256,后面的数字代表加密密钥的位数,位数越高呢,加密效果也就越好,更加不容易被破解。同时,位数越高,加密和解密过程中的计算量也会越大,内存占用也就更大,消耗的资源更多,需要的时间也就更长。
有利有弊,看你的需求而定。基本上,一般场景下 128位就足够安全了。AES 到目前为止,可以说没有漏洞,而且128位就可以保证不会被暴力破解。而更高位数的可能会用到国家级的保密数据上。
AES 是分组加密算法,除此之外,大部分的加密算法都是分组加密算法。
块加密算法就是将需要加密的数据分成一个个的固定长度的分组,比如 128位一组,然后分别用算法对每一组进行加密,如果最后一组不足128位的话,还要用填充算法进行填充,保证达到128位。
常用的分组算法有CTR和GCM,CTR 和 GCM 有并行计算的能力,并且, GCM 还能额外提供对消息完整性、真实性的验证能力。
所以我们在某些地方可能看到 AES-128-GCM、AES-256-CTR 这样的写法,前面表示加密算法,后面代表分组算法。
不足之处
对称加密本身从算法层面来说已经足够安全了,但是在密钥分发方面有些不太容易管理。
因为加解密的密钥相同。我加密的数据想要被别人使用,我就要把密钥告诉要使用的人。知道密钥的人、保存密钥的服务器越多,风险就越大。约束自己容易,约束别人难啊。但凡有一方不小心把密钥泄露就完。一个木桶能装多少水是由最低的一块木板决定的。
非对称加密
由于对称加密的密钥分发问题,非对称加密算法可以完美的解决。
刚毕业不就的时候,和第三方服务做集成,有关于接口参数加密的指引文档,虽然按照人家提供的 demo 可以正常集成,但是文档上说的公钥、私钥还是搞的很迷糊。
现在就来捋一捋啊,就以第三方服务角度来说。假设我是一个支付服务商,为大家提供支付接口的。
公钥
公钥是开放的,谁都可以获取。我作为一个支付服务商,任何到我平台上注册的用户都可以获取到公钥,公钥可以是相同的。
私钥
私钥是绝密的,我作为一个支付服务商,必须将私钥妥善保存,无论是保存在数据库中还是保存在服务器,都必须保证私钥不对外,只有我自己可以使用。
- 使用我服务的用户获取公钥;
- 调用方(客户)用公钥对接口参数进行加密,然后调用接口;
- 我方(支付服务商)用私钥对参数进行解密,然后使用参数进行业务处理;
以上是公钥、私钥的使用过程,这样一看就很明确了。
之所以说是非对称加密,是因为私钥只是用来解密的,用公钥加密过的数据,只有用对应的私钥才能解密出来。所以说,即使有那么多人得到了相同的公钥,也无法获取别人加密过的数据。
最常用的非对称加密算法是 RSA ,RSA 有1024、2048、3072、4096、8129、16384 甚至更多位。目前 3072 位及以上的密钥长度被认为是安全的,曾经大量使用的 2048 位 RSA 现在被破解的风险在不断提升,已经不推荐使用了
RSA 算法的性能要比对称加密 AES 算法差1000倍左右,虽然数学原理上不太明白,但是有数学常识就可以知道,使用不同的密钥进行加密和解密,必然要比使用相同密钥的算法复杂很多,由此肯定会带来性能上的开销。
非对称加密适合用于那些对安全性要求更高的场景,例如支付场景、数字证书、数据加密传输等等。
还可以看看风筝往期文章
用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了
来源:juejin.cn/post/7434615081394667571
如何在高德地图上制作立体POI图层
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
在基于GIS的数据可视化层面,我们能够展示的基本数据无非就是点线面体,其中,离散的点数据出现的情况相对较为普遍,通常POI(Point of Interest)的展示方式和丰富程度对于用户体验和地图的实用性有着重要的影响。在这篇技术分享文章中,我们将由浅入深地探讨如何在高德地图上创建大量立体 POI。相信通过本文的介绍,开发者能够受到启发,并且掌握这一个不错的技巧,为地图点数据的展示和应用带来新的视觉和功能体验。
需求分析
首先收集一波需求:在地图上展示大量的POI,能够配置用第三方工具制作的模型,作为POI的主体,能够实现基本的鼠标交互操作,比如鼠标悬浮状态下具有区别于其他POI的特殊的形态或者动画,每个POI能够根据自身属性出现特异的外观,再厉害一点的能不能实现固定POI在屏幕上的大小,即POI的尺寸不会随着地图缩放的远近而出现变化。
根据以上琐碎的内容我们可以整理为以下功能描述,下文我们将一步步实现这些需求:
- 支持灵活配置POI模型,POI样式可调整
- 能够支持大数据量(10000+)的POI展示
- 支持鼠标交互,能够对外派发事件
- 支持动画效果
- 支持开启一种模式,不会随地图远近缩放而改变POI的可见尺寸
实现步骤
从基础功能到进阶功能逐步完善这个POI图层,篇幅有限每个功能仅陈述开发原理以及核心代码,完整的代码工程可以到这里查看下载。
加载模型到场景中
- 首先讨论一个POI的情况要如何加载,以本文为例我们的POI是一个带波纹效果的倒椎体模型,根据后续的动画情况,我们把它拆成两个模型来实现。
- 把主体和托盘模型分别加载到场景中,并给它们替换为自己创建的材质,代码实现如下
// 加载单个模型
loadOneModel(sourceUrl) {
const loader = new GLTFLoader()
return new Promise(resolve => {
loader.load(sourceUrl, (gltf) => {
// 获取模型
const mesh = gltf.scene.children[0]
// 放大模型以便观察
const size = 100
mesh.scale.set(size, size, size)
// 放到场景中
this.scene.add(mesh)
resolve(mesh)
}
})
}
// 创建主体
async createMainMesh() {
// 加载主体模型
const model = await this.loadOneModel('../static/gltf/taper2.glb')
// 缓存模型
this._models.main = model
// 给模型换一种材质
const material = new THREE.MeshStandardMaterial({
color: 0x1171ee, //自身颜色
transparent: true,
opacity: 1, //透明度
metalness: 0.0, //金属性
roughness: 0.5, //粗糙度
emissive: new THREE.Color('#1171ee'), //发光颜色
emissiveIntensity: 0.2,
// blending: THREE.AdditiveBlending
})
model.material = material
}
// 创建托盘
async createTrayMesh() {
// 加载底部托盘
const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
// 缓存模型
this._models.tray = model
const loader = new THREE.TextureLoader()
const texture = await loader.loadAsync('../static/image/texture/texture_wave_circle4.png')
const { width, height } = texture.image
this._frameX = width / height
// xy方向纹理重复方式必须为平铺
texture.wrapS = texture.wrapT = THREE.RepeatWrapping
// 设置xy方向重复次数,x轴有frameX帧,仅取一帧
texture.repeat.set(1 / this._frameX, 1)
const material = new THREE.MeshStandardMaterial({
color: 0x1171ee,
map: texture,
transparent: true,
opacity: 0.8,
metalness: 0.0,
roughness: 0.6,
depthTest: true,
depthWrite: false
})
model.material = material
}
- 这样一来单个模型实现动画的效果很简单,对于旋转的主体,我们只需要在逐帧函数中更新主体的z轴旋转角度;而波纹的效果使用时序图的方式实现,原理类似于css sprite不断变化纹理图片的x轴位移。感兴趣看一看之前的文章有详细阐述过
update() {
const {main, tray} = this._models
// 更新托盘纹理
const texture = tray?.material?.map
if (texture) {
this._offset += 0.6
texture.offset.x = Math.floor(this._offset) / this._frameX
}
// 更新主体角度
if(main){
this._currentAngle += 0.005;
main.rotateZ((this._currentAngle / 180) * Math.PI);
}
}
- 对动画的速度参数进行一些调试,并增加适当的灯光,我们就可以得到以下结果(工程目录/pages/poi3dLayer0.html)
解决大量模型的性能问题
上文的方案用来处理数据量较小的场景基本上是没有问题的,然而现实中往往有大量散点数据的情况需要处理,这时候需要THREE.InstancedMesh
出手了,InstanceMesh用于高效地渲染大量相同几何形状但具有不同位置、旋转或其他属性的物体实例,使用它可以显著提高渲染性能,尤其是在需要渲染大量相似物体的场中,比如一片森林中的树木、一群相似的物体等。
- 首先获取数据,我们以数量为20个的POI数据为例,使用高德API提供的customCoords.lngLatsToCoords方法现将数据的地理坐标转换为空间坐标
// 处理转换图层基础数据的地理坐标为空间坐标
initData(geoJSON) {
const { features } = geoJSON
this._data = JSON.parse(JSON.stringify(features))
const coordsArr = this.customCoords.lngLatsToCoords(features.map(v => v.lngLat))
this._data.forEach((item, index) => {
item.coords = coordsArr[index]
})
}
- 我们对刚才的代码进行改造,模型加载之后不直接放置到场景scene而是存起来,加载完所有模型后为其逐个创建InstancedMesh。
// 加载主体模型
await this.loadMainMesh()
// 加载底座模型
await this.loadTrayMesh()
// 实例化模型
this.createInstancedMeshes()
async loadMainMesh() {
// 加载主体模型
const model = await this.loadOneModel('../static/gltf/taper2.glb')
// 缓存模型
this._models.main = model
//...
}
async loadTrayMesh() {
// 加载底部托盘
const model = await this.loadOneModel('../static/gltf/taper1-p.glb')
// 缓存模型
this._models.tray = model
//...
}
createInstancedMeshes() {
const { _models, _data, _materials, scene } = this
const keys = Object.keys(_models)
for (let i = 0; i < keys.length; i++) {
// 创建实例化模型
let key = keys[i]
const mesh = new THREE.InstancedMesh(_models[key].geometry, _materials[key], _data.length)
mesh.attrs = { modelId: key }
this._instanceMap[key] = mesh
// 实例化
this.updateInstancedMesh(mesh)
scene.add(mesh)
}
}
- 对每个InstancedMesh进行实例化,需要注意的一点是对instanceMesh进行变换操作时必须设置 instanceMatrix.needsUpdate=true,否则无效
// 用于做定位和移动的介质
_dummy = new THREE.Object3D()
updateInstancedMesh(instancedMesh) {
const { _data } = this
for (let i = 0; i < _data.length; i++) {
// 获得转换后的坐标
const [x, y] = this._data[i].coords
// 每个实例的尺寸
const newSize = this._size
this._dummy.scale.set(newSize, newSize, newSize)
// 更新每个实例的位置
this._dummy.position.set(x, y, i)
this._dummy.updateMatrix()
// 更新实例 变换矩阵
instancedMesh.setMatrixAt(i, this._dummy.matrix)
// 设置实例 颜色
instancedMesh.setColorAt(i, new THREE.Color(0xfbdd4f))
}
// 强制更新实例
instancedMesh.instanceMatrix.needsUpdate = true
}
- 实现动画效果,托盘的波纹动画不需要调整代码,因为所有实例都是用的同一个Material,主体模块需要instancedMesh.setMatrixAt 更新每一个数据。
_currentAngle = 0
// 逐帧更新图层
update() {
const { main, tray } = this._instanceMap
// 更新托盘纹理
const texture = tray?.material?.map
if (texture) {
this._offset += 0.6
texture.offset.x = Math.floor(this._offset) / this._frameX
}
// 更新主体旋转角度
this._data.forEach((item, index) => {
const [x, y] = item.coords
this.updateMatrixAt(main, {
size: this._size,
position: [x, y, 0],
rotation: [0, 0, this._currentAngle]
}, index)
})
// 更新主体旋转角度
this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle
// 强制更新instancedMesh实例,必须!
if (main?.instanceMatrix) {
main.instanceMatrix.needsUpdate = true
}
}
/**
* @description 更新指定网格体的单个示例的变化矩阵
* @param {instancedMesh} Mesh 网格体
* @param {Object} transform 变化设置,比如{size:1, position:[0,0,0], rotation:[0,0,0]}
* @param {Number} index 网格体实例索引值
*/
updateMatrixAt(mesh, transform, index) {
if (!mesh) {
return
}
const { size, position, rotation } = transform
const { _dummy } = this
// 更新尺寸
_dummy.scale.set(size, size, size)
// 更新dummy的位置和旋转角度
_dummy.position.set(position[0], position[1], position[2])
_dummy.rotation.x = rotation[0]
_dummy.rotation.y = rotation[1]
_dummy.rotation.z = rotation[2]
_dummy.updateMatrix()
mesh.setMatrixAt(index, _dummy.matrix)
}
- 最终效果如下,POI数量再翻10倍也能够保持较为流畅的体验
实现数据特异性
从上一步骤updateInstancedMesh方法中,我们不难发现在对每个POI进行实例化的时候都会调用一次变化装置矩阵和设置颜色,因此我们可以通过对每个POI设定不同的尺寸、朝向等空间状态来实现数据的特异性。
- 改进实例化方法,根据每个数据的scale和index索引值设置专有的尺寸和颜色
updateInstancedMesh(instancedMesh) {
const { _data } = this
for (let i = 0; i < _data.length; i++) {
// 获得转换后的坐标
const [x, y] = this._data[i].coords
// 每个实例的尺寸
const newSize = this._size * this._data[i].scale
this._dummy.scale.set(newSize, newSize, newSize)
// 更新每个实例的位置
this._dummy.position.set(x, y, i)
this._dummy.updateMatrix()
// 更新实例 变换矩阵
instancedMesh.setMatrixAt(i, this._dummy.matrix)
console.log(this._dummy.matrix)
// 设置实例 颜色
instancedMesh.setColorAt(i, new THREE.Color(this.getColor(i)))
}
// // 强制更新实例
instancedMesh.instanceMatrix.needsUpdate = true
}
// 获取实例颜色
getColor(index, data){
return index % 2 == 0 ? 0xfbdd4f : 0xff0000
}
- 在逐帧函数中调整setMatrixAt,对于每个动画中的POI,更新变化矩阵时也要带上scale
// 逐帧更新图层
update() {
// ...
// 更新主体旋转角度
this._data.forEach((item, index) => {
const [x, y] = item.coords
this.updateMatrixAt(main, {
size: item.scale * this._size,
//...
}, index)
})
- 最终效果如下(工程目录/pages/poi3dLayer1.html),对于使用instancedMesh实现的POI图层,POI的特异性也仅能做到这个程度;我们当然也可以实现主体模型上的特异性,在渲染图层前做一次枚举,为每一类主体模型创建一个instanceMesh即可,只不过instanceMesh的数量与数据量之间需要取得一个平衡,否则如果每个POI都是特定模型,使用instanceMesh就失去意义了。
实现鼠标交互
我们实现这样一种交互效果,所有POI主体静止不动,当鼠标悬浮在POI上,则POI开始转动画,且在POI上方出现广告牌显示它的名称属性。这里涉及到three.js中的射线碰撞检测和对外派发事件。主要的业务逻辑如下图:
- 对容器进行鼠标事件监听,每次mousemove时发射rayCast射线监控场景中物体碰撞并派发碰撞结果给onPick方法
_pickEvent = 'mousemove'
// ....
if (this._pickEvent) {
this.container.addEventListener(this._pickEvent, this.handleOnRay)
}
}
// ....
// onRay方法 防抖动
this.handleOnRay = _.debounce(this.onRay, 100, true)
/**
* 在光标位置创建一个射线,捕获物体
* @param event
* @return {*}
*/
onRay (event) {
const { scene, camera } = this
if (!scene) {
return
}
const pickPosition = this.setPickPosition(event)
this._raycaster.setFromCamera(pickPosition, camera)
const intersects = this._raycaster.intersectObjects(scene.children, true)
if (typeof this.onPicked === 'function' && this._interactAble) {
this.onPicked.apply(this, [{ targets: intersects, event }])
}
return intersects
}
- 在onPicked中处理碰撞结果,如果碰撞结果至少有1个,则将第一个结果作为当前鼠标拾取到的对象,为其赋值为拾取状态;如果碰撞结果为0个,则取消上一次拾取到的对象的拾取状态。
_lastPickIndex = {index: null}
/**
* 处理拾取事件
* @private
* @param targets
* @param event
*/
onPicked({ targets, event }) {
let attrs = null
if (targets.length > 0) {
const cMesh = targets[0].object
if (cMesh?.isInstancedMesh) {
const intersection = this._raycaster.intersectObject(cMesh, false)
// 获取目标序号
const { instanceId } = intersection[0]
// 设置选中状态
this.setLastPick(instanceId)
attrs = this._data[instanceId]
this.container.style.cursor = 'pointer'
}
} else {
if (this._lastPickIndex.index !== null) {
this.container.style.cursor = 'default'
}
this.removeLastPick()
}
// ...
}
/**
* 设置最后一次拾取的目标
* @param {Number} instanceId 目标序号
* @private
*/
setLastPick(index) {
this._lastPickIndex.index = index
}
/**
* 移除选中的模型状态
*/
removeLastPick() {
const { index } = this._lastPickIndex
if (index !== null) {
// 恢复实例化模型初始状态
const mainMesh = this._instanceMap['main']
const [x, y] = this._data[index].coords
this.updateMatrixAt(mainMesh, {
size: this._size,
position: [x, y, 0],
rotation: [0, 0, 0]
}, index)
}
this._lastPickIndex.index = null
}
- 修改逐帧函数,仅对当前拾取对象进行动画处理
// 逐帧更新图层
update() {
const { main, tray, } = this._instanceMap
const { _lastPickIndex, _size } = this
// ...
// 鼠标悬浮对象
if (_lastPickIndex.index !== null) {
const [x, y] = this._data[_lastPickIndex.index].coords
this.updateMatrixAt(main, {
size: _size * 1.2, // 选中的对象放大1.2倍
position: [x, y, 0], // 保持原位置
rotation: [0, 0, this._currentAngle] //调整旋转角度
}, _lastPickIndex.index)
}
// 更新旋转角度值
this._currentAngle = (this._currentAngle + 0.05) % this._maxAngle
// 强制更新instancedMesh实例,必须!
if (main?.instanceMatrix) {
main.instanceMatrix.needsUpdate = true
}
}
- 不管有没有拾取到,都将事件派发出去,让上层逻辑处理“广告牌”的显示情况,将广告牌移到当前拾取对象上方并设置显示内容为拾取对象的name
onPicked({ targets, event }) {
//...
// 派发pick事件
this.handleEvent('pick', {
screenX: event?.pixel?.x,
screenY: event?.pixel?.y,
attrs
})
}
// 上层逻辑监听图层的pick事件
layer.on('pick', (event) => {
const { screenX, screenY, attrs } = event
updateMarker(attrs)
})
let marker = new AMap.Marker({
content: '<div class="tip"></div>',
offset: [0, 0],
anchor: 'bottom-center',
map
})
// 更新广告牌
function updateMarker(attrs) {
if (attrs) {
const { lngLat, id, modelId, name } = attrs
marker.setPosition([...lngLat, 200])
marker.setContent(`<div class="tip">${name || id}</div>`)
marker.show()
} else {
marker.hide()
}
}
- 最终实现效果如下(工程目录/pages/poi3dLayer2.html)
实现PDI效果
PDI即像素密度无关模式,本意是使图形元素、界面布局和内容在各种不同像素密度的屏幕上都能保持相对一致的显示效果和视觉体验 ,在此我们借助这个概念作为配置参数,来实现POI不会随着地图远近缩放而更改尺寸的效果。
在这里我们会用到高德API提供的非常重要的方法Map.getResolution(),它用于获取指定位置的地图分辨率(单位:米/像素),即当前缩放尺度下,1个像素长度可以代表多少米长度,在每次地图缩放时POI示例必须根据这个系数进行缩放,才能保证在视觉上是没有变化尺寸的。
接下来进行代码实现,对上文的代码再次进行改造:
- 监听地图缩放事件
initMouseEvent() {
this.map.on("zoomchange", this.handelViewChange);
}
/**
* 初始化尺寸字典
* @private
*/
handelViewChange() {
if (this._conf.PDI) {
this.refreshTransformData();
this.updatePOIMesh();
}
}
- 重新计算当前每个模型的目标尺寸系数,实际情况下每个模型的尺寸可能是不同的,这里为了演示方便都设为1了;完了再执行updatePOIMesh重新设置每个POI的尺寸即可。
_sizeMap = {}
/**
* @description 重新计算每个模型的目标尺寸系数
* @private
*/
refreshTransformData() {
this._resolution = this.getResolution();
this._sizeMap["main"] = this._resolution * 1;
this._sizeMap["tray"] = this._resolution * 1;
}
/**
* @description 更新所有POI实例尺寸
*/
updatePOIMesh() {
const { _sizeMap } = this;
// 更新模型尺寸
const mainMesh = this._instanceMap["main"];
const trayMesh = this._instanceMap["tray"];
// 重置纹理偏移
if (this?._mtMap?.tray?.map) {
this._mtMap.tray.map.offset.x = 0;
}
for (let i = 0; i < this._data.length; i++) {
// 获取空间坐标
const [x, y] = this._data[i].coords;
// 变换主体
this.updateMatrixAt(
mainMesh,
{
size: _sizeMap.main ,
position: [x, y, 0],
rotation: [0, 0, 0],
},
i
);
// 变换托盘
this.updateMatrixAt(
trayMesh,
{
size: _sizeMap.tray ,
position: [x, y, 0],
rotation: [0, 0, 0],
},
i
);
}
// 强制更新instancedMesh实例
if (mainMesh?.instanceMatrix) {
mainMesh.instanceMatrix.needsUpdate = true;
}
if (trayMesh?.instanceMatrix) {
trayMesh.instanceMatrix.needsUpdate = true;
}
}
- 再逐帧函数中,由于当前选中对象的变化矩阵也随着动画在不断调整,因此也需要把PDI系数带进去计算(工程目录/pages/poi3dLayer3.html)
// 逐帧更新图层
update() {
//...
// 鼠标悬浮对象
if (_lastPickIndex.index !== null) {
const [x, y] = this._data[_lastPickIndex.index].coords;
const newSize = this._conf.PDI ? this._sizeMap.main: this._size
//...
}
//...
}
代码封装
最后为了让我们的代码具有复用性,我们将它封装为POI3dLayer类,将模型、颜色、尺寸、PDI、是否可交互、是否可动画等作为配置参数,具体操作可以看POI3dLayer.js这个类的写法。
//创建一个立体POI图层
async function initLayer() {
const map = getMap()
const features = await getData()
const layer = new POI3dLayer({
map,
data: { features },
size: 20,
PDI: false
})
layer.on('pick', (event) => {
const { screenX, screenY, attrs } = event
updateMarker(attrs)
})
}
// POI类的构造函数
/**
* 创建一个实例
* @param {Object} config
* @param {GeoJSON|Array} config.data 图层数据
* @param {ColorStyle} [config.colorStyle] 顔色配置
* @param {LabelConfig} [config.label] 用于显示POI顶部文本
* @param {ModelConfig[]} [config.models] POI 模型的相关配置数组,前2个成员modelId必须为main和tray
* @param {Number} [config.maxMainAltitude=1.0] 动画状态下,相对于初始位置的向上最大值, 必须大于minMainAltitude
* @param {Number} [config.minMainAltitude=0] 动画状态下,相对于初始位置的向下最小距离, 可以为负数
* @param {Number} [config.mainAltitudeSpeed=1.0] 动画状态下,垂直移动速度系数
* @param {Number} [config.rotateSpeed=1.0] 动画状态下,旋转速度
* @param {Number} [config.traySpeed=1.0] 动画状态下,圆环波动速度
* @param {Array} [config.scale=1.0] POI尺寸系数, 会被models[i].size覆盖
* @param {Boolean} [config.PDI=false] 像素密度无关(Pixel Density Independent)模式,开启后POI尺寸不会随着缩放而变化
* @param {Number} [config.intensity=1.0] 图层的光照强度系数
* @param {Boolean} [config.interact=true] 是否可交互
*/
class POI3dLayer extends Layer {
constructor (config) {
super(conf)
//...
}
}
这样一来我们配置模型和颜色就很便捷了,试试其他业务场景效果貌似也还可以,今天就到这里吧。
相关链接
来源:juejin.cn/post/7402068646166462502
新范式下的低代码方案
前言
低代码由来已久,主要是减少开发人员的编码时间,提高产品交付效率,甚至功能完备的低代码平台可以使非开发人员使用。
正如此,多年来,低代码平台层不出穷,但依然没有出现现象级的产品,不过,大多数公司也都有自己的低代码平台,体验和效果也是参差不齐。
新范式的到来
如今人工智能又达到了一个新的阶段,有预言说,它最先代替的是程序员。我认为这很难,如果你深度使用了ChatGPT,它确实非常非常惊艳,但是,要想取代程序员,还为时过早。不过如果能够善加利用,它将是一把利器,可以事半功倍。
但是,目前还没有发现一款体验非常好的GPT产品可以无缝的与开发环境融合。即使“历史悠久”的GitHub Copilot也没有达到一种理想效果,虽然还有它的孪生兄弟GitHub Copilot Chat也总是缺少一些体验。
虽然现在还没一个非常好的产品来辅助我们开发(GitHub Copilot已经很好了),但是也不影响一个新的范式即将到来,GPT注定会改变我们的开发方式。
目前,就我的体验来说,ChatGPT的自定义GPT体验是最好的,但是在开发的过程中,也相当割裂。下面我们就来定制这样一个GPT,看他的效果如何。
自定义GPT
假定你可以访问ChatGPT,并且开通了Plus。
自定义一个GPT和开发一个项目差不多,需要反复调试,第一版完成之后,还需迭代。但是,相对于开发项目,自定义一个GPT的调试过程要相对简单,我这里直接给出一个调试好的,你可以直接把这个指令粘贴到你的ChatGPT里。
你是一位资深的前端开发工程师,你的目标是根据用户要求创建一个不省略任何逻辑的,功能完备的组件。你需要跟用户沟通需求,满足条件之后进行组件开发。
你严格遵循如下规则完成组件开发:
- 使用最新的Typescript和Vue3进行开发。
- 使用Ant Design of Vue UI库。
- 代码结构必须是这样的:<template></template><script setup lang="ts"></script><style scoped></style>。
- 使用 Ant Design Vue 的栅格系统:利用 Ant Design Vue 的 <a-row> 和 <a-col> 组件创建响应式布局。
- 如果需要导航路由,必须这样:import { useRouter } from 'vue-router'; const router = useRouter(); router.push('xxx');
- 如果有URL相关访问,必须这样:import { useRoute } from 'vue-router'; const route = useRoute(); route.xxx
- 如果有提示、通知等,必须这样:import { notification, message, xxx } from 'ant-design-vue';
- 如果有Form表单,必须这样:<a-form @finish="handleLogin" :model="loginForm" :rules="rules" :label-col="{ span: 4 }" layout="horizontal">
- 如果组件有文字必须使用中文。
- 代码里避免使用any,复杂字段必须定义类型,可以参考api.json文档。
- 如果有数据提交按钮,按钮必须包含loading,防止多次触发。
- 如果有危险操作,进行二次确认。
- 如果有数据访问,做好下面处理:
- import axios from '@/axios-config';
- 根据api.json文档定义请求数据及响应的数据类型。
- 使用api.json提供的接口路径。
- 根据响应状态进行成功或者失败提示。
- 所有接口返回的数据类型都被如下范型包括:
[这里需要填写自己的]
- 输出代码之前:
- 必须先参考示例代码文件LoginView.vue【重要】
- 先把思路写出来,观察思路是否符合规则,之后再输出代码。
- 示例代码文件LoginView.vue内容如下:
[这里需要填写自己的]
你需要把 API 文档上传进去。
另外,可以根据开发过程遇到的问题,不断的调试这个指令,非常简单。
使用自定义GPT
上面,我们自定义了一个GPT,怎么使用呢?比如你可以输入这样的要求:
根据API文档及示例代码开发一个用户注册组件,要求如下:
1、根据API定义三个字段,邮箱、邮箱验证码、密码
2、邮箱后面需要有验证码发送按钮,参考下面代码
<a-input-search v-model:value="value" placeholder="input search text" size="large" @search="onSearch" >
<template #enterButton>
<a-button>Custom</a-button>
</template>
</a-input-search>
3、发送按钮要有一分钟倒计时功能
4、注册按钮下面要有一个跳转逻辑:已有账户登录?
它就会非常贴心的生成如下代码:
<template>
<a-row type="flex" justify="center">
<a-col :xs="24" :sm="16" :md="12">
<a-form @finish="handleRegister" :model="registerForm" :rules="rules" layout="horizontal">
<a-form-item label="邮箱" name="email">
<a-input-search
v-model:value="registerForm.email"
placeholder="请输入邮箱"
size="large"
@search="sendVerificationCode"
>
<template #enterButton>
<a-button :disabled="countdown > 0">{{ countdown > 0 ? `${countdown}秒` : '发送验证码' }}</a-button>
</template>
</a-input-search>
</a-form-item>
<a-form-item label="邮箱验证码" name="emailVerification">
<a-input v-model:value="registerForm.emailVerification" placeholder="请输入验证码" />
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item>
<a-button type="primary" block html-type="submit" :loading="loading">注册</a-button>
</a-form-item>
<a-form-item>
已有账户登录?<a @click="navigateToLogin">点击登录</a>
</a-form-item>
</a-form>
</a-col>
</a-row>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { notification } from 'ant-design-vue';
import axios from '@/axios-config';
interface RegisterModel {
email: string;
emailVerification: string;
password: string;
}
const registerForm = ref<RegisterModel>({
email: '',
emailVerification: '',
password: ''
});
const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur', type: 'email' }],
emailVerification: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
};
const loading = ref(false);
const countdown = ref(0);
const router = useRouter();
const sendVerificationCode = async () => {
if (countdown.value > 0) return;
// 发送验证码请求
try {
const response = await axios.get('/auth/verify-email', { params: { email: registerForm.value.email } });
if (response.appCode === 2000) {
notification.success({ message: response.message });
countdown.value = 60;
let interval = setInterval(() => {
countdown.value--;
if (countdown.value === 0) clearInterval(interval);
}, 1000);
} else {
notification.error({ message: response.message });
}
} catch (error) {
notification.error({ message: '验证码发送失败' });
}
};
const handleRegister = async () => {
try {
loading.value = true;
const response = await axios.post('/auth/register', registerForm.value);
if (response.appCode === 2000) {
notification.success({
message: '注册成功',
description: '您可以现在登录了'
});
router.push('/login');
} else {
notification.error({
message: '注册失败',
description: response.message
});
}
} finally {
loading.value = false;
}
};
const navigateToLogin = () => {
router.push('/login');
};
</script>
<style scoped>
</style>
效果非常好,如果有哪些不满足要求, 可以不断的与之沟通,继续完善代码。当然如果有些小的改动,可以直接在编辑器里修改代码。比如,上面的上面发送验证码的地方是有问题的,没有没有判断邮箱是否可用,这里你就可以自己加个逻辑,或者告诉它 发送邮箱验证码之前,必须校验邮箱
于是,它就会给你想要的逻辑,非常喜人。不过有时候他会给出代码片段,有时候也会给出全部代码,看自己情况要求它。
const isEmailValid = (email: string): boolean => {
const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
return emailRegex.test(email);
};
const sendVerificationCode = async () => {
if (!isEmailValid(registerForm.value.email)) {
notification.error({ message: '请输入有效的邮箱地址' });
return;
}
if (countdown.value > 0) return;
// 原有的发送验证码逻辑保持不变
// ...
};
总结
- 根据自己的业务需求,可以非常方便的调教出一个GPT。
- 在使用的过程中,写清楚自己的需求。
- 如果它生成的代码有问题,可以进一步沟通。
- 它生成的代码结构非常的清晰。
- 它命名的代码名称也非常的合理。
它现在能做到的已经非常棒了,未来会更加可期。
不过,从体验角度来说,我们需要在开发工具和ChatGPT之间来回切换,还是有些麻烦,什么时候,开发工具中,可以支持就太好。
还有一点儿,现在还需要我们自己去发现问题,与它沟通,指出问题,让它修正问题。如果有一个良好的产品,可以使用测试驱动开发,先让它生成测试代码,根据测试代码,生成逻辑代码。如果测试代码跑不通,自动的把错误消息反馈给它,它再修改,再跑测试代码,这体验简直逆天。
期望
希望一个体验非常完美的平台赶快到来:
- 调试好自己的GPT。
- 直接支持开发环境。
- 根据用户需求生成测试代码。
- 用户检查测试代码是否覆盖所有情况。
- 然后生成逻辑代码。
- 自动跑测试代码。
- 如果有问题,自动把问题反馈给它。
- 在此基础上再生成逻辑代码。
- 然后再跑测试代码。
- 循环往复。
- 实在不行开发人员介入解决问题。
- 最终完成开发。
如果有这样一个平台,那么,开发效率将是巨大的提升。
来源:juejin.cn/post/7322275119593144383
vue实现移动端扫一扫功能(带样式)
前言:
最近在做一个
vue2
的项目,其中有个需求是,通过扫一扫功能,扫二维码进入获取到对应的code
,根据code
值获取接口数据。在移动端开发中,扫一扫功能是一个非常实用的特性。它可以帮助用户快速获取信息、进行支付、添加好友等操作。而
Vue
作为一种流行的前端框架,为我们实现移动端扫一扫功能提供了强大的支持。本文将详细介绍如何使用
Vue
实现移动端扫一扫功能,并为其添加个性化的样式。
一、需要实现的效果图
二、背景
我这边的需求是,需要在移动端使用扫一扫功能进行物品的盘点。由于有的地方环境比较暗,所以要兼具“可开关手机手电筒”的功能,即上图中的“轻触点亮”。
本文主要介绍:
- 运用
vue-qrcode-reader
插件实现扫一扫功能;- 实现打开手电筒功能;
- 按照上图中的设计稿实现样式,并且中间蓝色短线是上下扫描的视觉效果。
三、下载并安装插件
- 可参考
vue-qrcode-reader
官网。 - 在项目
install
这个插件:
npm install --save vue-qecode-reader
或者
cnpm install --save vue-qrcode-reader
- 然后就可以在代码中引入了:
import { QrcodeStream } from 'vue-qrcode-reader';
components: {
QrcodeStream
},
html
中的结构可以这样写:
附上代码可直接复制:
<template>
<div class="saoma">
<qrcode-stream
:torch="torchActive"
@decode="onDecode"
@init="onInit"
style="height: 100vh; width:100vw">
<div>
<div class="qr-scanner">
<div class="box">
<div class="line">div>
<div class="angle">div>
<div @click="openTorch" class="openTorch">
<img src="@/assets/imgs/icon_torch.png" />
<div>轻触点亮div>
div>
div>
div>
div>
qrcode-stream>
div>
template>
API介绍可参考vue-qrcode-reader API介绍
js
中主要包含两个通用的事件和一个“轻触点亮”的事件:
注:
我这边的这个扫码页面,会根据情况分别跳转到两个页面,所以做了区分。
实现打开手电筒的功能时,要先自定义一个变量torchActive
,将初始值设置为false
,同时要注意在onDecode
方法中,要重置为false
。
下面将js
的全部代码附上:
CSS
可参考下面的代码,其中中间那条蓝色的短线是动态上线扫描的效果:
注:
- 颜色可自定义(我这边的主色是蓝色,可根据自己项目调整);
- 我的项目用的
css
语法是less
,也可根据自己项目修改。
这就是实现这个页面功能的全部代码了~
四、总结
读者可以通过本文介绍,根据自己的需求进行定制和扩展。无论是为了提高用户体验还是满足特定的业务需求,这个功能都能为你的移动端应用增添不少价值。
以上,希望对大家有帮助!
来源:juejin.cn/post/7436275126742712372
Dart 语法原来这么好玩儿
说到到某个语言的语法可能大家会觉得很枯燥、乏味,而日常开发中我们往往更加注重的是业务逻辑和页面开发,语法的使用大多也停留在满足基本的需求。其实 Dart
语法有很多有意思的地方的,仔细探究一下你会发现,它的简洁清晰、灵活多样的语法会让人爱不释手。在本文中,我们将探索 Dart 语法的各种奇妙之处吧。
unwrap
操作
在 Flutter
中,unwrap
操作常常用于处理可能为空的数据,以便过滤掉空值并只保留非空值。其使用场景也相当广泛,例如 为 Future
和 Streams
添加 unwrap
来处理掉非空数据,或者从网络请求或其他异步操作中获取数据,并在数据流中处理结果等等,如下面这段代码:
extension Unwrap<T> on Future<T?> {
Future<T> unwrap() => then(
(value) => value != null
? Future<T>.value(value)
: Future.any([]),
);
}
unwrap
函数将可能为空的 Future
解包,如果 Future
返回的值不为 null
,则将值包装在一个新的 Future
中返回,否则返回一个空的 Future
。调用示例:
class ImagePickerHelper {
static final ImagePicker _imagePicker = ImagePicker();
static Future<File> pickImageFromGallery() => _imagePicker
.pickImage(source: ImageSource.gallery)
.unwrap()
.then((xFile) => xFile.path)
.then((filePath) => File(filePath));
}
这里用到图片选择器插件 image_picker
,只有当返回的 xFile
不为空时才进行后续操作。如果不调用 unwrap
函数,此时这里返回的 xFile
为 optional
类型,要使用之前需要判断是否为 null
。日常开发中这种情况还不少,给 Future
添加 Unwrap
函数之后这样非空判断集中在这一个函数里面处理。
unwrap
不仅在 Future
中使用,还可以为 Streams
添加 unwrap
操作,代码如下:
extension Unwrap<T> on Stream<T?> {
Stream<T> unwrap() => where((event) => event != null).cast();
}
unwrap
方法,通过 where
过滤掉了 null
的事件,并使用 cast()
方法将结果转换为 Stream<T>
类型,将可空的事件转换为非空的事件流,下面是调用代码:
void main() {
Stream<int?>.periodic(
const Duration(seconds: 1),
(value) => value % 2 == 0 ? value : null,
).unwrap().listen((evenValue) {
print(evenValue);
});
/* 输出结果
0
2
4
6
...
*/
}
通过 extension
给 Future
和 Streams
添加 unwrap
函数后让我们的代码看起来清晰简洁多了,有没有?
数组的展开、合并和过滤
下面代码为任意类型的可迭代对象(Iterable
)添加名为 Flatten
的扩展。在这个扩展中,函数 flatten
使用了递归算法将多层嵌套的 Iterable
里面的所有元素扁平化为单层 Iterable
。
extension Flatten<T extends Object> on Iterable<T> {
Iterable<T> flatten() {
Iterable<T> _flatten(Iterable<T> list) sync* {
for (final value in list) {
if (value is List<T>) {
yield* _flatten(value);
} else {
yield value;
}
}
}
return _flatten(this);
}
}
注意了上面代码中使用了 yield
关键字,在 Flutter
中,yield
关键字用于生成迭代器,通常与sync*
或 async*
一起使用。它允许您在处理某些数据时逐步生成数据,而不是在内存中一次性处理所有数据。对于处理大量数据或执行长时间运行的操作非常有用,因为它可以节省内存并提高性能。
这个和 ES6
中使用 function*
语法和 yield
关键字来生成值一个东西,也是逐个生成值,而不需要一次性生成所有值。以下是 JS
写法:
function* generateNumbers(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
const numbers = generateNumbers(5);
for (const number of numbers) {
console.log(number);
}
我们来看看 Dart
中的 flatten()
函数的调用:
Future<void> main() async {
final flat = [
[[1, 2, 3], 4, 5],
[6, [7, [8, 9]], 10],
11,12
].flatten();
print(flat); // (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
}
嵌套的集合可能在数据处理、转换或展示中经常遇到,而将这些嵌套的集合扁平化可以简化数据处理过程,使代码更加简洁和易于理解。另外一点,递归展多维数组在面试中经常会出现,说不定哪天就用上了哈。
如果将两个数组合并成一个数组该怎么操作呢?其实和 Map
的合并相似,也是用到了自定义操作符 operator
,来看看怎么实现的。
extension InlineAdd<T> on Iterable<T> {
Iterable<T> operator +(T other) => followedBy([other]);
Iterable<T> operator &(Iterable<T> other) => followedBy(other);
}
void main() {
const Iterable<int> values = [10, 20, 30];
print((values & [40, 50]));
// 输出结果:(10, 20, 30, 40, 50)
}
添加了两个操作符:+
和 &
。将一个元素或者另一个可迭代对象添加到当前的可迭代对象中,然后返回一个新的可迭代对象,让可迭代对象 terable
有了合并数组的功能。
当数组中有一个为 null
的对象时,该如何过滤掉这个 null
对象呢,很简单可以这样做:
extension CompactMap<T> on Iterable<T?> {
Iterable<T> compactMap<E>([
E? Function(T?)? transform,
]) =>
map(transform ?? (e) => e).where((e) => e != null).cast();
}
void main() {
const list = ['Hello', null, 'World'];
print(list); // [Hello, null, World]
print(list.compactMap()); // [Hello, World]
print(list.compactMap((e) => e?.toUpperCase())); // [HELLO, WORLD]
}
Map
的过滤和合并
下面代码是 Map
类型的 extension
,为 Map
类型添加了查找过滤的函数。
extension DetailedWhere<K, V> on Map<K, V> {
Map<K, V> where(bool Function(K key, V value) f) => Map<K, V>.fromEntries(
entries.where((entry) => f(entry.key, entry.value)),
);
Map<K, V> whereKey(bool Function(K key) f) =>
{...where((key, value) => f(key))};
Map<K, V> whereValue(bool Function(V value) f) =>
{...where((key, value) => f(value))};
}
where
: 接受一个函数作为参数,该函数接受Map
的键和值作为参数,并返回一个布尔值。whereKey
: 接受一个只接受键作为参数的函数。whereValue
: 这个方法接受一个只接受值作为参数的函数。
下面是调用:
void main(){
const Map<String, int> people = {'John': 20, 'Mary': 21, 'Peter': 22};
print(people.where((key, value) => key.length > 4 && value > 20)); // {Peter: 22}
print(people.whereKey((key) => key.length < 5)); // {John: 20, Mary: 21}
print(people.whereValue((value) => value.isEven)); // {John: 20, Peter: 22}
}
其中 where
方法先使用 entries
获取 Map
的键值对列表,然后使用 entries.where
方法对列表中的每个键值对进行过滤,最后使用 fromEntries
方法将过滤后的键值对列表转换回 Map
,最后返回的新的 Map
中只包含满足条件的键值对,达到对 Map
中键值过滤的效果,也让代码更加简洁和易读。
Map
过滤还有另外一种写法
extension Filter<K, V> on Map<K, V> {
Iterable<MapEntry<K, V>> filter(
bool Function(MapEntry<K, V> entry) f,
) sync* {
for (final entry in entries) {
if (f(entry)) {
yield entry;
}
}
}
}
void main(){
const Map<String, int> people = {
'foo': 20,
'bar': 31,
'baz': 25,
'qux': 32,
};
final peopleOver30 = people.filter((e) => e.value > 30);
print(peopleOver30); // 输出结果:(MapEntry(bar: 31), MapEntry(qux: 32))
}
Map
其它一些更有趣的 extension
,如 Merge
功能,将两个 Map
合并成一个,代码如下:
extension Merge<K, V> on Map<K, V> {
Map<K, V> operator |(Map<K, V> other) => {...this}..addEntries(
other.entries,
);
}
上面的代码用到了 operator
关键字,在 Dart
中,operator
关键字用于定义自定义操作符或者重载现有的操作符。通过 operator
关键字,我们可以为自定义类定义各种操作符的行为,使得我们的类可以像内置类型一样使用操作符。
如 operator +
来定义两个对象相加的行为,operator []
来实现索引操作,operator ==
来定义相等性比较。这种语义式的也更加符合直觉、清晰易懂。
下面来看看 Map
的 Merge
功能调用代码例子:
const userInfo = {
'name': 'StellarRemi',
'age': 28,
};
const address = {
'address': 'shanghai',
'post_code': '200000',
};
void main() {
final allInfo = userInfo | address;
print(allInfo);
// 输出结果:{name: StellarRemi, age: 28, address: shanghai, post_code: 200000}
}
调用的时候也很简单直接 userInfo | address;
,这种操作在处理数据更新或合并配置等情况下特别有用。使用的时候需要注意的是,如果两个 Map
中有重复的键,那么上述操作会保留最后一个 Map
中的值。
小结
怎么样,上面的这些 Dart
的语法是不是很有意思,有没有函数式编程那味儿,后面还会单独一篇来分享 Dart
语言面向对象的设计。好了,今天就到这里,也希望通过本文的分享,能够激发大家对 Dart
语言的兴趣,感谢您的阅读,记得关注点赞哈。
来源:juejin.cn/post/7361096760449466406
10 个超赞的开发者工具,助你轻松提升效率
嗨,如果你像我一样,总是热衷于寻找新的方法让开发工作更轻松,那么你一定不能错过这篇文章!我精心挑选了 10 个 超级酷炫 的工具,可以让你效率倍增。无论是 API 管理、数据库操作还是调试最新项目,这里总有一款适合你。 而且,我还分享了一些你可能从未听过的全新工具。 让我们马上开始吧!
1. Hoppscotch — API 测试变得更简单 🐦
如果你曾经需要测试 API(谁没做过呢?),那么 Hoppscotch 就是你的新伙伴。它就像 Postman,但速度更快且开源。你可以用它测试 REST、GraphQL 甚至 WebSockets。它轻量级、易于使用,不会像一些臃肿的替代方案那样拖慢你的速度。
它为何如此酷炫: 它速度极快,非常适合测试 API,无需额外的功能。如果你追求速度,这就是你的不二之选。
🌍 网站: hoppscotch.io
2. Zed — 专业级代码协作 👩💻👨💻
让协作变得简单!Zed 是一款超级炫酷的代码编辑器,专为实时协作而设计。 如果你喜欢结对编程,或者仅仅需要与你的编码伙伴远程合作,这款工具会让你感觉就像并肩作战一样。 此外,它还拥有无干扰界面,让你专注于代码。
你为何会爱上它: 想象一下,你和你的团队就像坐在同一个房间里一样进行编码,即使你们相隔千里。 非常适合远程团队!
🌍 网站: zed.dev
3. Mintlify — 自动化文档,省时省力 📚
让我们面对现实:编写文档可不是什么让人兴奋的事情。 这就是 Mintlify 的用武之地。 它使用人工智能自动生成代码库文档,这意味着你可以专注于有趣的事情——编码! 此外,它会随着代码的更改而更新,因此你无需担心文档过时。
它为何是救星: 无需再手动编写文档! 该工具可以节省你的时间和精力,同时让你的项目文档井井有条。
🌍 网站: mintlify.com
4. Infisical — 安全保管秘密 🔐
管理敏感的环境变量可能很棘手,尤其是在不同团队之间。 Infisical 使这变得轻而易举,它可以安全地存储和管理秘密,例如 API 密钥和密码。 它开源且以安全性为中心构建,这意味着你的所有秘密都将安全且加密。
它为何如此方便: 安全,安全,安全。 Infisical 负责所有秘密管理,让你专注于构建酷炫的东西。
🌍 网站: infisical.com
5. Caddy — 带有自动 HTTPS 的 Web 服务器 🌐
如果你曾经不得不处理 Web 服务器配置,你就会知道这可能是一场噩梦。 Caddy 是一款现代 Web 服务器,它负责处理设置 HTTPS 自动化等繁琐工作。 它简单、快速且安全——相信我,使用这款工具设置 SSL 证书非常容易。
它为何如此赞: 无需再与服务器配置或安全设置作斗争。 Caddy 仅需点击几下即可为你处理所有事宜。
🌍 网站: caddyserver.com
6. TablePlus — 专业级数据库管理 🗄️
处理数据库? TablePlus 是一款时尚、超级易于使用的数据库管理工具,支持所有主要数据库,例如 MySQL、PostgreSQL 等。 它拥有简洁的界面,管理数据库查询从未如此简单。 此外,它速度很快,因此你可以快速完成任务,无需等待。
它为何如此酷炫: 支持多种数据库类型,并拥有出色的 UI,TablePlus 让数据库管理变得轻而易举。
🌍 网站: tableplus.com
7. JSON Crack — 以全新视角可视化 JSON 数据 🧩
JSON 很快就会变得混乱不堪。 这就是 JSON Crack 的用武之地。 它允许你将 JSON 数据可视化为交互式图表,使其更易于理解、调试,甚至与团队分享。 告别在嵌套数据中无限滚动。
它为何如此酷炫: 就像 JSON 数据的 X 光透视! 你只需一瞥就能看到复杂的数据结构。
🌍 网站: jsoncrack.com
8. Signoz — DevOps 的开源监控工具 💻
如果你处理的是后端应用程序或从事 DevOps 工作,那么 Signoz 是必不可少的工具。 它提供应用程序的全面可观察性,包括日志、指标和分布式跟踪——所有这些都在一个地方。 此外,它是开源的,如果你喜欢自行托管,这非常棒。
它为何如此重要: 就像监控和调试应用程序的瑞士军刀。 你可以在问题变得严重之前捕捉到错误和性能问题。
🌍 网站: signoz.io
9. Warp — 更智能的终端 🖥️
终端多年来几乎没有变化,但 Warp 正在改变这一现状。 它是一款现代终端,具有富文本、命令共享和协作功能。 你甚至可以实时查看你的团队在输入什么内容。 此外,它快速直观——非常适合所有终端高级用户。
你为何会爱上它: 如果你常在终端工作,Warp 会让你的生活更轻松。 协作功能也是一个不错的加分项!
🌍 网站: warp.dev
10. Gleek.io — 文本绘图工具 ✏️➡️📊
需要快速绘制图表,但又不想使用拖放工具? Gleek.io 允许你仅通过输入文本创建图表。 它非常适合那些习惯写作而不是绘画的开发者,并且支持 UML、流程图和实体关系图等。
它为何如此赞: 就像魔法一样。 输入几行文本,然后——你就会得到一张图表。 它超级快,非常适合规划下一个大项目。
🌍 网站: gleek.io
总结
以上就是我推荐的 10 个工具,我相信它们会让你的开发者生活 无比 轻松。 无论你是独自工作还是与团队合作,这些工具旨在节省你的时间、提高你的效率,而且说实话,它们能让你编码更加愉快。 赶快试试吧,告诉我你最喜欢哪些工具!
来源:juejin.cn/post/7434471758819901452
仿今日头条,H5 循环播放的通知栏如何实现?
我们在各大 App 活动页面,经常会看到循环播放的通知栏。比如春节期间,我就在今日头条 App 中看到了如下通知:「春节期间,部分商品受物流影响延迟发货,请耐心等待,祝你新春快乐!」。
那么,这种循环播放的通知栏如何实现呢?本文我会先介绍它的布局、再介绍它的逻辑,并给出完整的代码。最终我实现的效果如下:
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
布局代码
我们先看布局,如下图所示,循环播放的布局不是普通的左中右布局。可以看到,当文字向左移动时,左边的通知 Icon 和右边的留白会把文字挡住一部分。
为了实现这样的效果,我们给容器 box 设置一个相对定位,并把 box 中的 HTML 代码分为三部分:
- 第一部分是 content,它包裹着需要循环播放的文字;
- 第二部分是 left,它是左边的通知 Icon,我们给它设置绝对定位和
left: 0;
; - 第三部分是 right,它是右边的留白,我们给它设置绝对定位和
right: 0;
。
<div class="box">
<div class="content">
<!-- ... 省略 -->
</div>
<div class="left">🔔</div>
<div class="right"></div>
</div>
.box {
position: relative;
overflow: hidden;
/* ... 省略 */
}
.left {
position: absolute;
left: 0;
/* ... 省略 */
}
.right {
position: absolute;
right: 0;
/* ... 省略 */
}
现在我们来看包裹文字的 content。content 内部包裹了三段一模一样的文字 notice,每段 notice 之间还有一个 space 元素作为间距。
<!-- ... 省略 -->
<div id="content">
<div class="notice">春节期间,部分商品...</div>
<div class="space"></div>
<div class="notice">春节期间,部分商品...</div>
<div class="space"></div>
<div class="notice">春节期间,部分商品...</div>
</div>
<!-- ... 省略 -->
为什么要放置三段一模一样的文字呢?这和循环播放的逻辑有关。
逻辑代码
我们并没有实现真正的循环播放,而是欺骗了用户的视觉。如下图所示:
- 播放通知时,content 从 0 开始向左移动。
- 向左移动
2 * noticeWidth + spaceWidth
时,继续向左移动便会露馅。因为第 3 段文字后不会有第 4 段文字。
如果我们把 content 向左移动的距离强行从
2 * noticeWidth + spaceWidth
改为noticeWidth
,不难看出,用户在 box 可视区域内看到的情况基本一致的。
然后 content 继续向左移动,向左移动的距离大于等于
2 * noticeWidth + spaceWidth
时,就把距离重新设为noticeWidth
。循环往复,就能欺骗用户视觉,让用户认为 content 能无休无止向左移动。
欺骗视觉的代码如下:
- 我们通过修改 translateX,让 content 不断地向左移动,每次向左移动 1.5px;
- 当
translateX >= noticeWidth * 2 + spaceWidth
时,我们又会把 translateX 强制设为noticeWidth
; - 为了保证移动动画更丝滑,我们并没有采用 setInterval,而是使用 requestAnimationFrame。
const content = document.getElementById("content");
const notice = document.getElementsByClassName("notice");
const space = document.getElementsByClassName("space");
const noticeWidth = notice[0].offsetWidth;
const spaceWidth = space[0].offsetWidth;
let translateX = 0;
function move() {
translateX += 1.5;
if (translateX >= noticeWidth * 2 + spaceWidth) {
translateX = noticeWidth;
}
content.style.transform = `translateX(${-translateX}px)`;
requestAnimationFrame(move);
}
move();
完整代码
完整代码如下,你可以在 codepen 或者码上掘金上查看。
- codepen: codepen.io/lijunlin202…
- 码上掘金:
总结
本文我介绍了如何用 H5 实现循环播放的通知栏:
- 布局方面,我们需要用绝对定位的通知 Icon、留白挡住循环文字的左侧和右侧;此外,循环播放的文字我们额外复制 2 份。
- 逻辑方面,通知栏向左移动
2 * noticeWidth + spaceWidth
后,我们需要强制把通知栏向左移动的距离从2 * noticeWidth + spaceWidth
变为noticeWidth
,以此来欺骗用户视觉。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
来源:juejin.cn/post/7372765277460496394
JavaScript 中的 ‘return’ 是什么意思?
最近朋友问了我一个问题:“JavaScript 中的 return
是什么意思?”
function contains(px, py, x, y) {
const d = dist(px, py, x, y);
if (d > 20) return true; // 这行是什么意思?
else return false; // 那这一行呢?
}
一开始我觉得这个问题很简单,但它背后其实蕴藏了一些重要且有趣的概念!
因为我朋友是艺术背景,所以本篇文章的结论是一些很基础的东西,大家感兴趣可以继续看下去。
两种函数
我先解释了有 return
和没有 return
的函数的区别。函数是一组指令,如果你需要这组指令的执行结果,就需要一个 return
语句,否则不需要。
例如,要获得两个数的和,你应该声明一个带有 return
语句的 add 函数:
function add(x, y) {
return x + y; // 带有 return 语句
}
然后你可以这样使用 add 函数:
const a = 1;
const b = 2;
const c = add(a, b); // 3
const d = add(b, c); // 5
如果你只是想在控制台打印一条消息,则不需要在函数中使用 return
语句:
function great(name) {
console.log(`Hello ${name}!`);
}
你可以这样使用 great 函数:
great('Rachel');
我原以为我已经解答了朋友的问题,但她又提出了一个新问题:“为什么我们需要这个求和函数?我们可以在任何地方写 a + b
,那为什么还要用 return
语句?”
const a = 1;
const b = 2;
const c = a + b; // 3
const d = b + c; // 5
此时,我意识到她的真正问题是:“我们为什么需要函数?”
为什么需要函数?
为什么要使用函数?尽管有经验的程序员有无数的理由,这里我只关注一些与我朋友问题相关的原因
可重用的代码
她的确有道理。我们可以轻松地在任何地方写 a + b
。然而,这仅仅因为加法是一个简单的操作。如果你想执行一个更复杂的计算呢?
const a = 1;
const b = 2;
// 这是否易于在每个地方写?
const c = 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
如果你需要多个语句来获得结果呢?
const a = 1;
const b = 2;
// t 是一个临时变量
const t = 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
const c = t ** 2;
在这两种情况下,重复编写这些代码会很麻烦。对于这种可重用的代码,你可以将其封装在一个函数中,这样每次需要它时就不必重新实现了!
function theta(a, b) {
return 0.6 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
}
const a = 1;
const b = 2;
const c = theta(a, b);
const d = theta(b, c);
易于维护
在讨论可重用性时,你无法忽视可维护性。唯一不变的是世界总是在变化,这对于代码也一样!你的代码越容易修改,它就越具可维护性。
如果你想在计算结果时将 0.6
改为 0.8
,没有函数的情况下,你必须在每个执行计算的地方进行更改。但如果有一个函数,你只需更改一个地方:函数内部!
function theta(a, b) {
// 将 0.6 更改为 0.8,你就完成了!
return 0.8 + 0.2 * Math.cos(a * 6.0 + Math.cos(d * 8.0 + b));
}
毫无疑问,函数增强了代码的可维护性。就在我以为我解答了她的问题时,她又提出了另一个问题:“我理解了函数的必要性,但为什么我们需要写 return
?”
为什么需要 return
?
真有意思!我之前没有考虑过这个问题!她随后提出了一些关于 return
的替代方案,这些想法非常有创意!
为什么不直接返回最后一条语句?
第一个建议的方案是“为什么不直接返回最后一条语句?”
function add(a, b) {
a + b
}
const sum = add(1, 2); // undefined
我们知道,在 JavaScript、Java、C 或许多其他语言中,这样是不允许的。这些语言的规范要求显式的 return
语句。然而,在某些语言中,例如 Rust,这是允许的:
fn add(a: i32, b: i32) -> i32 {
a + b
}
let sum = add(1, 2); // 3
然而值得注意的是,JavaScript 中的另一种函数类型不需要 return
语句!那就是带有单个表达式的箭头函数:
const add = (x, y) => x + y;
const sum = add(1, 2); // 3
如果我们将结果赋值给局部变量呢?
然后她提出了另一个有创意的解决方案:“如果我们将结果赋值给一个局部变量呢?”
function add(x, y) {
let sum = x + y;
}
add(1, 2);
sum; // Uncaught ReferenceError: sum is not defined
她很快注意到我们无法访问 sum
变量。这是因为使用 let
关键字声明的变量只在其定义的作用域内可见——在这个例子中是函数作用域。
可以将函数视为黑盒子。你将参数放入盒子中,期待获得一个输出(返回值)。只有返回值对外部世界(父作用域)是可见的(或可访问的)。
将结果赋值给全局变量呢?
如果我们在函数作用域之外访问这个值呢?将其赋值给一个全局变量怎么样?
let sum;
function add(x, y) {
sum = x + y;
}
add(1, 2);
sum; // 3
啊,修改全局变量!副作用!非纯函数!这些想法在我脑海中浮现。但我如何在一分钟内解释为什么这是一个糟糕的选择呢?
避免这种方法的一个关键原因是,别人很难知道具体的全局变量是在哪个函数中被修改的。他们需要去查找结果在哪儿,而不是直接从函数中获取!
总结
简而言之,我们需要 return
,因为我们需要函数,而在 JavaScript 中的标准函数中没有可行的替代方案。
函数的存在是为了使代码具有可重用性和可维护性。由于 JavaScript 的规范、函数作用域的限制以及修改全局变量带来的风险,我们在 JavaScript 的标准函数中必须使用 return
语句。
这次讨论非常有趣!我从未想过看似简单的问题背后竟然蕴含着如此多的有趣的思考。与不同视角的人交流总能带来新的见解!
来源:juejin.cn/post/7434460436307591177
关于前端压缩字体的方法
我在编写一个撰写日常的网站,需要用到自定义字体,在网上找到一ttf的字体,发现体积很大,需要进行压缩
如何压缩
目前我们的字体是.ttf字体,其实我们需要把字体转换成.woff字体
WOFF本质上是包含了基于SFNT的字体(如TrueType、OpenType或其他开放字体格式),且这些字体均经过WOFF的编码工具压缩,以便嵌入网页中。[3]WOFF 1.0使用zlib压缩,[3]文件大小一般比TTF小40%。[11]而WOFF 2.0使用Brotli压缩,文件大小比上一版小30%
可以看下实际效果
20M 转换为 9M 大小,效果还是很明显
transfonter
这个网站transfonter.org/只接受转换15M以下的字体
工具压缩
先下载这个工具字体压缩工具下载,这个工具是从Google的代码编译而来,是用Cygwin编译的,Windows下可以使用
解压出来后大概包含以下几个文件
下载后打开,其中包括woff2_compress.exe
和woff2_decompress.exe
,使用方法很简单使用命令行:
woff2_compress myfont.ttf
woff2_decompress myfont.woff2
实测效果还不错
来源:juejin.cn/post/7436015589527273522
项目开发时越来越卡?多半是桶文件用多了!
前言
无论是开发性能优化还是生产性能优化如果你想找资料那真是一抓一大把,而且方案万变不离其宗并已趋于成熟,但是有一个点很多人没有关注到,在铺天盖地的性能优化文章中几乎很少出现它的影子,它就是桶文件
(barrel files),今天我们就来聊一聊。
虽然大家都没怎么提及过,但是你肯定都或多或少地在项目中使用过,而且还对你的项目产生不小的影响!
那么什么是桶文件?
无论是开发性能优化还是生产性能优化如果你想找资料那真是一抓一大把,而且方案万变不离其宗并已趋于成熟,但是有一个点很多人没有关注到,在铺天盖地的性能优化文章中几乎很少出现它的影子,它就是桶文件
(barrel files),今天我们就来聊一聊。
虽然大家都没怎么提及过,但是你肯定都或多或少地在项目中使用过,而且还对你的项目产生不小的影响!
那么什么是桶文件?
桶文件 barrel files
桶文件是一种将多个模块的导出汇总到一个模块中的方式。具体来说,桶文件本身是一个模块文件,它重新导出其他模块的选定导出。
原始文件结构
// demo/foo.ts
export class Foo {}
// demo/bar.ts
export class Bar {}
// demo/baz.ts
export class Baz {}
不使用桶文件时的导入方式:
import { Foo } from '../demo/foo';
import { Bar } from '../demo/bar';
import { Baz } from '../demo/baz';
使用桶文件导出(通常是 index.ts)后:
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
使用桶文件时的导入方式:
import { Foo, Bar, Baz } from '../demo';
是不是很熟悉,应该有很多人经常这么写吧,尤其是封装工具时 utils/index
。
还有这种形式的桶文件:
// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';
这都是大家平常很常用到的形式,那么用桶文件到底怎么了?
桶文件是一种将多个模块的导出汇总到一个模块中的方式。具体来说,桶文件本身是一个模块文件,它重新导出其他模块的选定导出。
原始文件结构
// demo/foo.ts
export class Foo {}
// demo/bar.ts
export class Bar {}
// demo/baz.ts
export class Baz {}
不使用桶文件时的导入方式:
import { Foo } from '../demo/foo';
import { Bar } from '../demo/bar';
import { Baz } from '../demo/baz';
使用桶文件导出(通常是 index.ts)后:
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
使用桶文件时的导入方式:
import { Foo, Bar, Baz } from '../demo';
是不是很熟悉,应该有很多人经常这么写吧,尤其是封装工具时 utils/index
。
还有这种形式的桶文件:
// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';
这都是大家平常很常用到的形式,那么用桶文件到底怎么了?
桶文件的优缺点
先来说结论:
优点:
- 集中管理,简化代码
- 统一命名,利于多人合作
缺点:
- 增加编译、打包时间
- 增加包体积
- 不必要的性能和内存消耗
- 降低代码可读性
嗯,有没有激起你的好奇心?我们一个一个来解释。
先来说结论:
优点:
- 集中管理,简化代码
- 统一命名,利于多人合作
缺点:
- 增加编译、打包时间
- 增加包体积
- 不必要的性能和内存消耗
- 降低代码可读性
嗯,有没有激起你的好奇心?我们一个一个来解释。
增加编译、打包时间
桶文件对打包工具的影响
我们都知道 tree-shaking
,他可以在打包时分析出哪些模块和代码没有用到,从而在打包时将这些没有用到的部分移除,从而减少包体积。
以 rollup
为例,tree-shaking 的实现原理(其他大同小异)是:
1.静态分析
- Tree-shaking 基于 ES Module 的静态模块结构进行分析
- 通过分析 import/export 语句,构建模块依赖图
- 标记哪些代码被使用,哪些未被使用
- 使用 /#PURE/ 和 /@NO_SIDE_EFFECTS/ 注释来标记未使用代码
- 死代码消除
- 移除未使用的导出
- 移除未使用的纯函数
- 保留有副作用的代码
我们都知道 tree-shaking
,他可以在打包时分析出哪些模块和代码没有用到,从而在打包时将这些没有用到的部分移除,从而减少包体积。
以 rollup
为例,tree-shaking 的实现原理(其他大同小异)是:
1.静态分析
- Tree-shaking 基于 ES Module 的静态模块结构进行分析
- 通过分析 import/export 语句,构建模块依赖图
- 标记哪些代码被使用,哪些未被使用
- 使用 /#PURE/ 和 /@NO_SIDE_EFFECTS/ 注释来标记未使用代码
- 死代码消除
tree-shaking 实现流程
- 模块分析阶段
// 源代码
import { a, b } from './module'
console.log(a)
// 分析:b 未被使用
- 构建追踪
// 构建依赖图
module -> a -> used
module -> b -> unused
- 代码生成
// 最终只保留使用的代码
import { a } from './module'
console.log(a)
更多细节可以看我的另一篇文章关于tree-shaking,这不是这篇文章的重点 。
接着说回来,为什么桶文件会增加编译、打包时间?
如果你使用支持 tree-shaking 的打包工具,那么在打包时打包工具需要分析每个模块是否被使用,而桶文件作为入口整合了模块并重新导出,所以会增加分析的复杂度,你重导出的模块越多,它分析的时间就越长。
那有聪明的小伙伴就会问,既然 tree-shaking 分析、标记、删除无用代码会降低打包效率,那我关闭 tree-shaking 功能怎么样?
我只能说,不怎么样,有些情况你关闭 tree-shaking 后,打包时间反而更长。为啥?
关闭 Tree Shaking 意味着 Rollup 会直接将所有模块完整打包,即使某些模块中的代码未被使用。结果是:
- 打包体积增大:更多的代码需要进行语法转换、压缩等步骤。
- I/O 操作增加:较大的输出文件需要更多时间写入磁盘。
- 模块合并工作量增加:Rollup 在关闭 Tree Shaking 时仍会尝试将模块合并到一个文件中(尤其是 output.format 为 iife 或 esm 时)。
所以,虽然 Tree Shaking 的静态分析阶段可能较慢,但其最终生成的 bundle 通常更小、更优化,反而会减少后续步骤(如 压缩 和 代码生成)的负担。
又跑题了,我其实想说的是,问题不在于是否开启 tree-shaking,而在于你使用了桶文件,导致打包工具苦不堪言。
这个很好理解,你就想下面的桶文件重导出了100个模块,相当于这个文件里包含了100个模块的代码,解析器肯定一视同仁每一行代码都得照顾到,但其实你就用了其中一个方法 import { Foo } from '../demo';
,想想都累...
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...
下面这两种形式,比上面的稍微强点
// components/index.ts
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
假设 ./Button 文件导出多个具名导出和一个默认导出,那么这段代码意味着只使用其中的默认导出,而 export *
则是照单全收。
export {foo} from './foo';
export {bar} from './bar';
export {baz} from './baz';
同理,假设 ./foo
中有100个具名导出,这行代码就只使用了其中的 foo
。
即使这比export *
强,但是当重导出的模块较多较复杂时对打包工具依然负担不小。
好难啊。。。,那到底要怎么样打包工具才舒服?
最佳建议
- 包或者模块支持通过具体路径引入即所谓的“按需导入” 如:
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
- 包或者模块支持通过具体路径引入即所谓的“按需导入” 如:
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
不知道有没有人用过 babel-plugin-import
,它的工作原理大概就是
import { Button, Divider } from 'antd';
帮你转换为
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
- 减少或避免使用桶文件,将模块按功能细粒度分组,且要控制单个文件的导出数量
例如:
import {formatTime} from 'utils/timeUtils';
import {formatNumber} from 'utils/numberUtils';
import {formatMoney} from 'utils/moneyUtils';
...
而不是使用桶文件统一导出
import { formatTime, formatNumber, formatMoney } from 'utils/index';
其实这和生产环境的代码拆分一个意思,你把一个项目的所有代码都放在一个文件里搞个几M,浏览器下载和解析肯定是慢的
另外,不止打包阶段,本地开发也是一样的,无论是 vite
还是 webpack
,桶文件都会影响解析编译速度,你的桶文件搞得很多很复杂页面初始加载时间就会很长。
这一点 vite 的官方文档中也有说明。
增加包体积
有的小伙伴可能想,桶文件只影响开发和打包时的体验?那没事,我不差那点时间。
肯定没那么简单,桶文件也会影响打包后产物的体积,这就切实影响到用户侧的体验了。
如果你在打包时没有刻意关注 treeshaking 的效果,或者压根就没开启,那么你无形之中就打包了很多无用代码进最终产物里去了,这就是桶文件带来的坑。
如果你有计划的想要优化打包体积,那么桶文件会额外给你带来很多心智负担,你要一边看着打包产物一边调试打包工具的各种配置,以确保打包结果符合你的预期。
// components/utils/index.ts (桶文件)
export * from './chart'; // 依赖 echarts
export * from './format'; // 纯工具函数
export * from './i18n'; // 依赖 i18next
export * from './storage'; // 浏览器 API
// 使用桶文件
import { formatDate } from 'components/utils';
// 可能导致加载所有依赖
上面的代码,即使开启了 tree-shaking ,打包工具也无能为力。
好在较新版本的 Rollup 已针对export * from
进行了优化,只要最终代码路径中没有实际使用的导出项,它仍会尝试移除这些未使用的代码。但在以下场景下仍可能有问题:
- 模块间有副作用:如果重新导出的模块执行了副作用代码(如修改全局变量),Rollup 会保留这些模块。
- 与 CommonJS 混用:如果被导入模块是 CommonJS 格式,Tree Shaking 可能会受到限制。
想了解完整的影响 treeshaking 的场景点这里传送 Rollup 的 Tree Shaking
不仅 vite,rollup官网也说明了使用桶文件导入的弊端。
总之就是,使用桶文件如果不开 treeshaking,那么打包产物体积大,开了treeshaking也没办法做到完美(目前),你还得多花很多心思去分析优化,就没必要嘛。
不必要的性能和内存消耗
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...
// demo/index.ts
export * from './foo';
export * from './bar';
export * from './baz';
...
这点就很好理解了,即使你只 import {foo} from 'demo/index'
使用了一个模块,其他模块也是被初始化了的,这些初始化是没有任何意义的,但是却可能拖累你的初始加载速度、增加内存占用。
同理,他也会影响你的IDE的性能,例如代码检查、补全等,或者测试框架 jest 等。
降低代码可读性
这一点见仁见智,我个人觉得桶文件增加了追踪实现的复杂性,当然大部分情况我们使用IDE是可以直接跳转到对应文件或者搜索的,不然用桶文件真的很抓狂。
// 使用桶文件
import { something } from '@/utils';
// 难以知道 something 的具体来源
// 直接导入更清晰
import { something } from '@/utils/atool';
总结
看到这里快去你的项目里检查一下,你可能做一个很小的改动就能让旁边小伙伴刮目相看:你做了what?这个项目怎么突然快了这么多?
桶文件实际上产生的影响并不小,只有少量桶文件在您的代码中通常是没问题的,但当每个文件夹都有一个时,问题就大了。
如果的项目是一个广泛使用桶文件的项目,现在可以应用一项免费的优化,使许多任务的速度提高 60-80%,让你的IDE和构建工具减减负:
删除所有桶文件!
来源:juejin.cn/post/7435492245912551436
程序员裸辞创业, 都混的咋样了?
嗨, 大家好, 我是徐小夕.
没错, 我已经辞职快一年了, 目前在全职创业, 今天会和大家系统的分享一下接近一年的创业历程, 能“劝退”一个是一个!
写这篇文章主要有两个目的, 一个是对自己创业做个阶段性复盘, 另一个目的是给一些在筹备创业的小伙伴, 一些客观而有参考性的建议.
因为我发现最近几个月很多技术小伙伴都在咨询我创业的事情, 我突然有点不知所措, 心想现在创业行情都这么“好”了吗? 于是我我看了一眼技术社区的画风, 仿佛他们读懂了我的疑惑:
已有先辈们做出了“总结”......
就我自己亲身做互联网软件创业来说, 确实不太“易”.
接下来就分享一下我这一年的创业历程.
裸辞后为什么选择创业
两个字: 执念!
因为之前一直在深耕低代码可视化领域, 对可视化搭建产品有很大的执念, 所以辞职的首要目标就是要做一款高价值的可视化搭建产品.
虽然在之前的公司工作非常愉快, 不管是薪资还是和同事们的相处, 感觉都非常nice, 但是心里总是有一个“坎”, 想要去迈过.
也可能是为了让即将到来的30岁, 不留遗憾吧~
很多朋友认识我大多通过技术社区里我做的技术分享和技术文章, 在开源领域, 我也做了很多可视化相关的开源项目, 从而树立了一定的技术影响力:
由于自己坚持做技术分享和写文章已经3年多了, 积累了大概 10w 的粉丝, 所以这也为自己创业做了一定的“用户”保障.(你做的产品一定要有原始用户)
总结一下, 决定我创业的原因有以下3个核心要素:
- 技术积累
- 资金积累
- 粉丝积累
如果大家想创业, 尤其是从事互联网创业, 我觉得得好好思考上面3点, 缺一勿“创”.
毕竟刚才在说话的间隙, 我又刷到一波高赞的文章, 和我的文章遥相呼应:
再来聊聊创业收入.
由于我们的创业方向主要是给企业提供可视化解决方案, 加上最近大环境又不太好, 很多企业的预算都在降低, 所以我们产品价格定位在企业可以接受的较低档位.
10个月时间, 收入6位数+.
好在我们是轻资本创业, 后面会通过提高产品价值和市场力, 来提高产品盈利能力.
创业过程中的酸甜苦辣
之前在网上看到有个大V说: 创业最可怕的事是,一开始就看到了终点那个举着胜利旗帜的人不会是你。
直到真正创业了, 我才感同身受. 因为你的每一个决策都决定着产品的未来走向, 意味着你需要为你的决策付出时间, 精力, 财力, 和人情.
1. 产品研发的血泪史
当时听到“自由无价”这个词, 用来形容独立开发者和自由职业者时, 觉得非常'cool', 直到自己亲身下场创业, 才发现自由是“相对的”, 创业没成功之前, 你是绝对不自由的.
也许关注可视化搭建项目的朋友知道我之前开源了一个零代码的项目——H5-Dooring, 今年一直在做Saas版的零代码产品, 让不懂技术的朋友也能通过拖拽的方式使用搭建平台来低成本制作页面.
从1月到6月, 我们几个小伙伴兵分两路, 一路给客户做定制开发, 一路迭代我们自己的低代码平台, 基本上天天熬夜coding, 测试, 同时还要兼顾用户反馈的需求和 bug.
由于大家都没有市场销售经验, 所以我自然而然的担起了这个责任.
除了要做技术研发, 我硬挤出了一部分时间来做内容宣传, 学习视频剪辑, 录制产品宣传视频和视频教程, 然后客户确定购买后我还需要给客户做技术培训, 帮助客户快速上手可视化搭建方案的技术研发.
所以说, 我也终于理解为什么说创业者都得是多面手了, 因为大部分事情都要亲力亲为!
再来聊聊软件产品的发布上线经验.
因为Dooring线上产品有近2万用户, 所以产品的更新迭代都极为谨慎, 我们一般在凌晨12点发布新版本, 这也就是意味着我们需要为发版“疯狂”. 由于没有专业测试, 我们都得自测, 当然百密总有一疏, 也出现几次线上bug导致用户受到了影响, 好在我们及时修复了, 才避免了损失.
这里的经验教训就是, 需要根据自己用户的属性, 来沉淀适合产品自身的迭代周期和BUG追溯机制.
凌晨发版是国内企业的不二选择......
这些都是创业过程中需要经历的冰山一角, 还有很多困难等着各位, 比如攻克技术难题, 风险管控, 成本等, 这些会不断考验着创业者的心智.
一次失败的百度广告
百度广告也许是我们做的最亏的一次投资.
Dooring零代码
其实有做3个月的百度广告, 当时主要是为了提高搜索权重和用户曝光, 来提高转化, 于是我们商量之后投了几万的百度广告.
由于百度是竞价机制, 在投放期间, 我们发现搜索关键词排名还是没有在第一页, 搜索排名基本上被“大公司”垄断, 投的钱越多你的位置越靠前, 我们当时的底线就是用户检索关键词后我们的网站只要在第一页就行了, 后面发现还是差强人意.
所以对于小公司, 做百度广告基本效果很小, 如果没有几十万上百万的投入, 很难有实际效果.
所以我们后面果断放弃了百度广告, 转而从内容侧和网站自身SEO来提高搜索曝光度. 大家对百度广告, 怎么看呢? 欢迎留言区留下你的经验分享~
学会做一名销售
说实话, 程序员创业, 营销或者销售能力是一个短板. 然而企业的基础目标就是“卖货”, 要想把自己产品销售出去, 就必须得懂客户市场.
当然销售能力也是我在创业过程中遇到的短板.
今年参加了很多线下的分享会, 有关于AI的, 也有创业的, 也认识了很多的创业者, 他们大多都有技术背景, 但是我发现发展比较好的企业基本上都是极度重视用户需求和体验的, 他们建立了各种渠道来挖掘用户需求和用户反馈, 从而改进优化自己的产品,让更多的用户来主动帮它“代言” .
往往是那种死磕技术但是忽视真正的用户体验的产品, 虽然技术虽强, 但是在使用产品初期, 就把大部分用户“劝退”了.
很多技术人更倾向于把技术做好, 以技术思维来设计产品. 但是产品光有技术是不行的, 还得懂客户, 懂用户体验, 我们需要把自己假想成产品的“客户”, 如果“客户”都觉得这个有价值, 有购买意愿, 这个产品才能更好的成单.
这里举一个大家都比较熟悉的例子——雷军的小米SU7.
虽然小米不是第一个造车, 但是一场汽车发布会下来, 却让一众老牌车企“洗面”.
当时也看了很多博主分享小米汽车爆单的原因, 其中最重要的就是用户体验. 在发布会上雷总真的是吊足了观众的胃口和对年轻人第一辆“xx车”的期望, 其爆款的背后是小米对大量用户诉求的思考, 并融入到了产品的设计中.
虽然我个人对这种产品模式不太苟同, 但是有一点是比较赞同的, 就是: 创业者要做一个懂客户的金牌销售.
国内的客户关系管理涉及到方方面面, 作为创业者一定要考虑客户关系的维护和管理.
这里分享我的几个创业经验:
- 建立客户群(需求挖掘和市场洞察)
- 建立产品用户群(提高产品体验, 获取产品体验数据)
- 做好数据埋点(进一步分析产品决策, 用户属性)
- 持续迭代产品
- 在不影响大方向的前提下, 市场反馈优于技术实现
- 做好产品营销(不要指望客户主动来找你)
打造竞争壁垒
我觉得AI盛行的当下, 小团队做互联网产品创业, 在不融资的情况下, 你的唯二的竞争壁垒就是:
- 铁粉基数
- 产品垂直度
因为任何软件技术都能被复制, 只要有足够的资金, 那如何才能和这些喜欢 copy 的公司竞争呢?
第一个就是你的粉丝基数, 如果你有大量的粉丝, 那么你已经成功一半了, 因为你有了第一批可能会帮你传播的用户, 并且能很快确定在当前行业的影响力.(当然后续的口碑也非常重要, 为了保证粉丝不会倒戈~)
其次我觉得就是你的创业项目得在垂直细分领域做的足够优秀, 至少达到Top3的水平. 不然很难在这个领域获取可持续的客户, 所以需要做深一个垂直细分领域, 这样才能形成一定的竞争壁垒.
说到这里, 还在规划做互联网创业的技术小伙伴们, 是否觉得有一丝丝压力了?
不急, 我来继续复盘.
短期收益 or 长期收益?
再来聊聊一个比较实际的话题, 就是创业的收益.
当然创业的方式有很多很多种, 有短期收益模式的, 比如说卖课, 广告等, 也有长期才能获取收益的, 比如做独立产品, 而我创业的方向是走长期收益的方向——做独立产品.
我相信很多技术圈友想创业的方向都是自研产品, 但是有一个现实的问题就是, 你的收益周期可能是3个月, 半年, 1年, 甚至更久. 而投入的成本来说也比较大, 比如服务器, 研发成本, 设计资源, 营销投入等.
我们在做可视化搭建产品的过程中(比如Dooring, flowmix系列), 累计投入超过了20w, 还不包含人工研发成本. 虽然目前已经盈利, 但是还是需要持续的投入, 而且要做好半年不盈利的准备.
所以对于想独立做产品创业的小伙伴, 大家一定要慎重, 如果都具备了我上面说的条件, 大家再入局, 不然会非常“惨烈”的.
创业这一年, 都做了什么
接下来到了文章的“高潮”部分, 聊聊从1月到10月, 我们做了哪些可视化搭建产品.
1.Dooring零代码搭建平台
2. Dooring智图, 在线图片设计平台
- 橙子试卷, 表单问卷搭建平台
4. flowmix/docx 多模态文档编辑引擎
5. flowmix/flow 多模态工作流设计器
未来的规划
后续还是会持续在可视化零代码领域做深耕, 同时借助AI技术, 让应用搭建门槛降到最低.
如果创业不达预期, 我可能也会像下面的博主一样, 继续在职场中卷卷卷啦!
如果大家有好的创业想法, 或者想有一些技术交流, 欢迎随时和我沟通, 也可以在留言区和大家分享哦~
来源:juejin.cn/post/7425600234523131956
微信的消息订阅,就是小程序有通知,可以直接发到你的微信上
给客户做了一个信息发布的小程序,今天客户提要求说希望用户发布信息了以后,他能收到信息,然后即时给用户审核,并且要免费,我就想到了微信的订阅消息。之前做过一次,但是忘了,这次记录一下,还是有一些坑的。
一 先申请消息模版
先去微信公众平台,申请消息模版
在uni-app 里面下载这个插件uni-subscribemsg
我的原则就是有插件用插件,别自己造轮子。而且这个插件文档很好
根据文档定义一个message.js 云函数
这个其实文档里面都有现成的代码,但我还是贴一下自己的吧。
'use strict';
const uidObj = require('uni-id');
const {
Controller
} = require('uni-cloud-router');
// 引入uni-subscribemsg公共模块
const UniSubscribemsg = require('uni-subscribemsg');
// 初始化实例
let uniSubscribemsg = new UniSubscribemsg({
dcloudAppid: "填你的应用id",
provider: "weixin-mp",
});
module.exports = class messagesController extends Controller {
// 发送消息
async send() {
let response = { code: 1, msg: '发送消息失败', datas: {} };
const {
openid,
data,
} = this.ctx.data;
// 发送订阅消息
let resdata = await uniSubscribemsg.sendSubscribeMessage({
touser: openid,// 就是用户的微信id,决定发给他
template_id: "填你刚刚申请的消息模版id",
page: "pages/tabbar/home", // 小程序页面地址
miniprogram_state: "developer", // 跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
lang: "zh_CN",
data: {
thing1: {
value: "信息审核通知"// 消息标题
},
thing2: {
value: '你有新的内容需要审核' // 消息内容
},
number3: {
value: 1 // 未读数量
},
thing4: {
value: '管理员' // 发送人
},
time7: {
value: data.time // 发送时间
}
}
});
response.code = 0;
response.msg = '发送消息成功';
response.datas = resdata;
return response;
}
}
四 让用户主动订阅消息
微信为了防止打扰用户,需要用户订阅消息,并且每次订阅只能发送一次,不过我取巧,在用户操作按钮上偷偷加订阅方法,让用户一直订阅,我就可以一直发
// 订阅
dingYue() {
uni.requestSubscribeMessage({
tmplIds: ["消息模版id"], // 改成你的小程序订阅消息模板id
success: (res) => {
if (res['消息模版id'] == 'accept') {
}
}
});
},
五 讲一下坑
我安装了那个uni-app 的消息插件,但是一直报错找不到那个模块。原来是unicloud 云函数要主动关联公共模块,什么意思呢,直接上图。
又是一个人的前行
如果你喜欢我的文章,可以关注我的公众号,九月有风,上面更新更及时
来源:juejin.cn/post/7430353222685048859
「差生文具多」增大IDE字体,增大显示器
🍄 大家好,我是风筝
🌍 个人博客:【古时的风筝】。
本文目的为个人学习记录及知识分享。如果有什么不正确、不严谨的地方请及时指正,不胜感激。
每一个赞都是我前进的动力。
公众号:「古时的风筝」
上个月发“微博”的时候说IDE字体已经从 16号调到18号了,主要是一到换季的时候就有点鼻炎,导致眼睛也不舒服,看电脑时间长了就很累,结果我就给字体调大了。
你别说,该认怂就得认怂,调大了不丢人,字体一大果然就没那么累了。刚毕业的时候用12号字,后来改14号,再后来16号,这不,转眼18号字了,如果过两年还写代码的话,就奔着20号去了。
但是问题还是有的,我一般情况下是不怎么用外接显示器的,16寸写代码感觉够用了,除非要做一些大块儿内容的比对,否则,我的外接显示器就是一张风景图,搁旁边就剩下赏心悦目的作用了。
但是这18号字体一上,如果开着控制台的话,一屏也就能显示20行代码了。我之所以用 MacBook Pro,很大的一个原因就是显示屏,但是公司的外接显示器应该是 2K 显示器里最便宜的了,显示效果跟苹果比差了一大截。
看到我发的“微博”后,几个朋友就在群里聊起显示器来,最后一致认为苹果 Studio Display 是最好的显示器,唯一的缺点就是太贵了,最低的27寸 11499起,这你受的了吗?
一个醉心研究显示器和机械键盘的朋友让我看看明基(BenQ)RD280U,说是专业的编程显示器,而且护眼这一块儿做的特别到位,他说,你不是最关心这个吗,肯定对路。他说入手不想要的话可以转给他,他正好想把之前的华为显示器换了呢。
我查了一晚上资料后,这款显示器有两点最吸引我的地方:
- 它为编程场景专门做过定制设计,保证开发的时候能有更好的体验。
- 有专业的护眼硬件滤蓝光设置,看评论说用起来眼睛真的不累。
然后它就来了,28寸4K,好大一个箱子。快递小哥说这箱子还挺沉,我说是啊,一个显示器怎么这么大这么沉,快递小哥也有点儿惊讶。
拆开后,一个大箱子里面套了一个小箱子,但是是真的沉,好不夸张的说得有20多斤,料真足啊。
但是安装很方便,直接把底座扣上就好了。
这是显示器的上桌效果,28寸,实话说已经很大了,再大的话,全屏写代码就有点儿费脖子了。
一般的显示器都是27寸 16:9 比例的,但是 RD280U 是3:2的,这样显示器更高一点,能多显示几行代码。
目前市面上特殊屏幕比的显示器商家很少,有成本、市场等很多原因,但也有很多程序员喜欢用方一点的屏幕,RD这款考虑到了,还挺贴心。对于我来说,这倒是小事,反正屏幕够大,也不差那几行代码。我最关心的还是显示效果,代码显示的清不清楚。
用惯了苹果的屏幕,其他的显示器一眼就能看出来清晰不清晰,结果,果然没让我失望。文字显示非常清晰,效果非常好。
专门的编码主题
这个显示器内置了几种模式,其中就包括专门的编码主题,一个是深色主题、一个是亮色主题,写代码的时候就直接调成这俩模式就行了。看电影、读书也有专门的主题。
同时还有个专门的背光灯,晚上写代码就直接开背光灯就可以了,不用再单独开个台灯了,也不用开着室内灯了,有时候思路真的不能被打断,晚上背光灯亮起来就不用着急去开灯了,而且氛围感也不错。
另外,在晚上开启猫头鹰模式,也就是夜间保护模式,可以自动调节亮度,在搭配上深色模式,晚上写代码也很舒服。
反光处理
它对定点光源的反光效果处理的也很好,左边是 MacBook Pro,右边是 RD280U,效果还是很明显的。
但是建议写代码、看视频还是不要在强光下,总是对眼睛不好的。
用软件助手直接控制
与显示器配套的还有一个软件控制工具,叫做 「Display Pilot 2」,通过这个软件可以直接更改色彩主题、亮度。
还可以开启 MoonHalo ,MoonHalo 就是背光灯,还有夜间保护、智慧蓝光,还附赠个桌面分区的功能。
软件助手里还有一个 Flow 功能,可以自定义固定时间段里我想要的显示器色彩模式、护眼功能开启状态和其他参数。比如我晚上八点想看会电子书提前设置好Flow, 到点就直接切换了,非常丝滑。
还有一个用处
我家小朋友最近正在学习认表,我就给做了一个认表的网页,没事儿可以拿着大屏练习几分钟。
地址: http://www.moonkite.cn/clock-study…
点击暂停,表盘就会停止,这时候就可以让他看看是几点了,然后点击显示按钮就会出现刚才暂停的时刻。
有小孩子的可以拿来用一用。
然后顺道改了改,做了一个在线时钟,不用大屏的时候可以看时间用。
来源:juejin.cn/post/7436036522019143707
anime,超强JS动画库和它的盈利模式
大家好,我是农村程序员,独立开发者,前端之虎陈随易。
前面分享了开源编辑器 wangEditor
维护九年终停更,隐藏大佬接大旗的故事。
本文呢,分享开源项目进行商业化盈利的故事。
这个项目叫做 anime
,是一个 JavaScript 动画库,目前有 50k star
。
我们先来看看它的效果。
怎么样?是不是大呼过瘾。
而这,只是 anime 的冰山一角,更多案例,可以访问下方官网查看。
github:https://github.com/juliangarnier/anime
anime
的第一次提交时间是 2016年6月27日
,到如今 8年
来,一共提交了 752次
,平均每年提交 100次
,核心代码 1300行
左右。
从数据上来看,并不亮眼,但是从功能上来说,确是极其优秀。
目前,anime v4
版本已经可以使用了。
v4 版本的功能特点如下:
- 新的 ES 模块优先 API。
- 主要性能提升和减少内存占用。
- 内置类型定义!
- 用于检查和加速动画工作流程的 GUI 界面。
- 带有标签的改进时间轴、更多时间位置语法选项、对子项的循环/方向支持等等!。
- 用于创建附加动画的新附加合成模式。
- 新的可配置内置功能:‘linear(x,x,x)’、‘in(x)’、‘out(x)’、‘inOut(x)’、‘outIn(x)’。
- 更好的 SVG 工具,包括改进的形状变形、线条绘制和运动路径实用程序。
- 支持 CSS 变量动画。
- 能够从特定值进行动画处理。
- 可链接的实用程序函数可简化动画工作流程中的常见任务。
- 新的 Timer 实用程序类,可用作 setInterval 和 setTimeout 的替代方案。
- 超过 300 个测试,使开发过程更轻松且无错误。
- 全新的文档,具有新设计和更深入的解释。
- 新的演示和示例。
可以看到,新版进行了大量的优化和升级。
但是呢,目前只提供给赞助的用户使用。
最低档赞助是 10美元/月
,目标是 120个
赞助,目前已经积累了 117个
赞助。
也就是说,每个月都会有至少 1170美元
的赞助收入,折合人民币 8400元/月
。
不知道作者所在地区的生活水平怎么样,这个赞助收入,对于生存问题,基本能够胜任了。
我们很多时候都在抱怨开源赚不到钱,那么开源盈利的方案也是有很多的,比如:
- 旧版免费,新版付费使用。
- 源码免费,文档或咨询付费。
- 开源免费,定制服务付费。
希望我们的开源环境更加友好,让更多人可以解决他们的问题,也要让开源作者获得应有的回报。
来源:juejin.cn/post/7435959580506914816
Flutter 新一代混合栈管理框架(已适配HarmonyOS Next)
简介
Fusion 是新一代的混合栈管理框架,用于 Flutter 与 Native 页面统一管理,并支持页面通信、页面生命周期监听等功能。Fusion 即 融合
,我们的设计初衷就是帮助开发者在使用 Flutter 与 Native 进行混合开发时尽量感受不到两者的隔阂,提升开发体验。此外,Fusion 彻底解决了混合开发过程中普遍存在的黑屏、白屏、闪屏等问题,更加适合重视用户体验的App使用。
从 4.0 开始,Fusion 已完成纯鸿蒙平台(HarmonyOS Next/OpenHarmony,以下简称 HarmonyOS)的适配,开发者可以在Android、iOS、HarmonyOS上得到完全一致的体验。(HarmonyOS 的 Flutter SDK 可以在这里获取)
OS | Android | iOS | HarmonyOS |
---|---|---|---|
SDK | 5.0(21)+ | 11.0+ | 4.1(11)+ |
Fusion 采用引擎复用方案,在 Flutter 与 Native 页面多次跳转情况下,APP 始终仅有一份 FlutterEngine 实例,因此拥有更好的性能和更低的内存占用。
Fusion 也是目前仅有的支持混合开发时应用在后台被系统回收后,所有Flutter页面均可正常恢复的混合栈框架。
开始使用
0、准备
在开始前需要按照 Flutter 官方文档,将 Flutter Module 项目接入到 Android、iOS、HarmonyOS 工程中。
1、初始化
Flutter 侧
使用 FusionApp 替换之前使用的 App Widget,并传入所需路由表,默认路由表和自定义路由表可单独设置也可同时设置。
void main() {
runApp(FusionApp(
// 默认路由表
routeMap: routeMap,
// 自定义路由表
customRouteMap: customRouteMap,
));
}
// 默认路由表,使用默认的 PageRoute
// 使用统一的路由动画
final Map<String, FusionPageFactory> routeMap = {
'/test': (arguments) => TestPage(arguments: arguments),
kUnknownRoute: (arguments) => UnknownPage(arguments: arguments),
};
// 自定义路由表,可自定义 PageRoute
// 比如:某些页面需要特定的路由动画则可使用该路由表
final Map<String, FusionPageCustomFactory> customRouteMap = {
'/mine': (settings) => PageRouteBuilder(
opaque: false,
settings: settings,
pageBuilder: (_, __, ___) => MinePage(
arguments: settings.arguments as Map<String, dynamic>?)),
};
P.S: kUnknownRoute
表示未定义路由
注意:如果项目使用了 flutter_screenutil
,需要在 runApp 前调用 Fusion.instance.install()
,没有使用 flutter_screenutil
则无须该步骤。
void main() {
Fusion.instance.install();
runApp(FusionApp(
// 默认路由表
routeMap: routeMap,
// 自定义路由表
customRouteMap: customRouteMap,
));
}
Android 侧
在 Application 中进行初始化,并实现 FusionRouteDelegate 接口
class MyApplication : Application(), FusionRouteDelegate {
override fun onCreate() {
super.onCreate()
Fusion.install(this, this)
}
override fun pushNativeRoute(name: String?, arguments: Map<String, Any>?) {
// 根据路由 name 跳转对应 Native 页面
}
override fun pushFlutterRoute(name: String?, arguments: Map<String, Any>?) {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
}
}
iOS 侧
在 AppDelegate 中进行初始化,并实现 FusionRouteDelegate 代理
@UIApplicationMain
@objc class AppDelegate: UIResponder, UIApplicationDelegate, FusionRouteDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
...
Fusion.instance.install(self)
...
return true
}
func pushNativeRoute(name: String?, arguments: Dictionary<String, Any>?) {
// 根据路由 name 跳转对应 Native 页面
}
func pushFlutterRoute(name: String?, arguments: Dictionary<String, Any>?) {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
// 可在 arguments 中存放参数判断是 push 还是 present
}
}
HarmonyOS 侧
在 UIAbility 中进行初始化,并实现 FusionRouteDelegate 代理
export default class EntryAbility extends UIAbility implements FusionRouteDelegate {
private static TAG = 'EntryAbility'
private mainWindow: window.Window | null = null
private windowStage: window.WindowStage | null = null
override async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
await Fusion.instance.install(this.context, this)
GeneratedPluginRegistrant.registerWith(Fusion.instance.defaultEngine!)
}
pushNativeRoute(name: string, args: Map<string, Object> | null): void {
// 根据路由 name 跳转对应 Native 页面
}
pushFlutterRoute(name: string, args: Map<string, Object> | null): void {
// 根据路由 name 跳转对应 Flutter 页面
// 可在 arguments 中存放参数判断是否需要打开透明页面
}
}
2、Flutter 容器
普通页面模式
Android 侧
通过 FusionActivity
(或其子类) 创建 Flutter 容器,启动容器时需要使用 Fusion 提供的 buildFusionIntent
方法,其中参数 transparent
需设为 false。其 xml 配置参考如下(如果使用 FusionActivity
则不用配置):
<activity
android:name=".CustomFusionActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="standard"
android:theme="@style/FusionNormalTheme"
android:windowSoftInputMode="adjustResize" />
iOS 侧
通过 FusionViewController
(或其子类)创建 Flutter 容器,push
和 present
均支持。FusionViewController 默认隐藏了 UINavigationController。
在 iOS 中需要处理原生右滑退出手势和 Flutter 手势冲突的问题,解决方法也很简单:只需在自定义的 Flutter 容器中实现 FusionPopGestureHandler
并在对应方法中启用或者关闭原生手势即可,这样可以实现如果当前 Flutter 容器存在多个 Flutter 页面时,右滑手势是退出 Flutter 页面,而当 Flutter 页面只有一个时则右滑退出 Flutter 容器。
// 启用原生手势
func enablePopGesture() {
// 以下代码仅做演示,不可直接照搬,需根据APP实际情况自行实现
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
// 关闭原生手势
func disablePopGesture() {
// 以下代码仅做演示,不可直接照搬,需根据APP实际情况自行实现
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
HarmonyOS 侧
通过 FusionEntry
(或其子类) 创建 Flutter 容器,启动容器时需要使用 Fusion 提供的 buildFusionParams
方法,也可直接使用 FusionPage
。默认全屏模式。
const params = buildFusionParams(name, args, false, backgroundColor)
this.mainLocalStorage?.setOrCreate('params', params)
router.pushNamedRoute({name: FusionConstant.FUSION_ROUTE_NAME})
透明页面模式
Android 侧
使用方式与普通页面模式相似,只是buildFusionIntent
方法的参数 transparent
需设为 true,其 xml 配置参考如下:
<activity
android:name=".TransparentFusionActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="standard"
android:theme="@style/FusionTransparentTheme"
android:windowSoftInputMode="adjustResize" />
iOS 侧
使用方式与普通页面模式相似:
let fusionVc = CustomViewController(routeName: name, routeArguments: arguments, transparent: true)
navController?.present(fusionVc, animated: false)
HarmonyOS 侧
使用方式与普通页面模式相似:
const params = buildFusionParams(name, args, true, backgroundColor)
this.windowStage?.createSubWindow(FusionConstant.TRANSPARENT_WINDOW, (_, win) => {
const record: Record<string, Object> = {
'params': params
}
win.loadContentByName(FusionConstant.FUSION_ROUTE_NAME, new LocalStorage(record))
win.showWindow()
})
Flutter 侧
同时Flutter页面背景也需要设置为透明
子页面模式
子页面模式是指一个或多个 Flutter 页面同时嵌入到 Native 容器中的场景,如:使用Tab切换Flutter和原生页面,Fusion 支持多个 Flutter 页面嵌入同一个 Native 容器中
Android 侧
使用 FusionFragment 以支持子页面模式,创建 FusionFragment 对象需要使用 buildFusionFragment
方法
iOS 侧
与页面模式一样使用 FusionViewController
HarmonyOS 侧
与页面模式一样使用 FusionEntry,配合 buildFusionParams
方法配置参数
自定义容器背景色
默认情况下容器的背景为白色,这是因为考虑到绝大多数的页面都是使用白色背景,但如果打开的首个Flutter页面的背景是其他颜色,比如夜间模式下页面为深灰色,此时是为了更好的视觉效果,可以自定义容器的背景色与首个Flutter页面的背景色一致。
Android 侧
在 buildFusionIntent
和 buildFusionFragment
方法中参数 backgroundColor
设为所需背景色
iOS 侧
在创建 FusionViewController (或其子类)对象时,参数 backgroundColor
设为所需背景色
HarmonyOS 侧
在 buildFusionParams
方法中参数 backgroundColor
设为所需背景色
3、路由API(FusionNavigator)
- push:将对应路由入栈,Navigator.pushNamed 与之等同,根据FusionRouteType分为以下几种方式:
- flutter模式: 在当前Flutter容器中将指定路由对应的Flutter页面入栈,如果没有则跳转kUnknownRoute对应Flutter页面
- flutterWithContainer模式: 创建一个新的Flutter容器,并将指定路由对应的Flutter页面入栈,如果没有则跳转kUnknownRoute对应Flutter页面。即执行FusionRouteDelegate的pushFlutterRoute
- native模式: 将指定路由对应的Native页面入栈,即执行FusionRouteDelegate的pushNativeRoute
- adaption模式: 自适应模式,默认类型。首先判断该路由是否是Flutter路由,如果不是则进入native模式,如果是再判断当前是否是页面是否是Flutter容器,如果是则进入flutter模式,如果不是则进入flutterWithContainer模式
- pop:在当前Flutter容器中将栈顶路由出栈,Navigator.pop 与之等同
- maybePop:在当前Flutter容器中将栈顶路由出栈,可被WillPopScope拦截
- replace:在当前Flutter容器中将栈顶路由替换为对应路由,Navigator.pushReplacementNamed 与之等同
- remove:在当前Flutter容器中移除对应路由
路由跳转与关闭等操作既可使用FusionNavigator
的 API,也可使用Navigator
中与之对应的API(仅上述提到的部分)
4、Flutter Plugin 注册
在 Android 和 iOS 平台上框架内部会自动注册插件,无须手动调用 GeneratedPluginRegistrant.registerWith
进行注册,但 HarmonyOS 必须手动调用该方法。
5、自定义 Channel
如果需要 Native 与 Flutter 进行通信,则需要自行创建 Channel,创建 Channel 方式如下(以 MethodChannel 为例):
Android 侧
①、与容器无关的方法
在 Application 中进行注册
val channel = Fusion.defaultEngine?.dartExecutor?.binaryMessenger?.let {
MethodChannel(
it,
"custom_channel"
)
}
channel?.setMethodCallHandler { call, result ->
}
②、与容器相关的方法
在自实现的 FusionActivity、FusionFragmentActivity、FusionFragment 上实现 FusionMessengerHandler 接口,在 configureFlutterChannel 中创建 Channel,在 releaseFlutterChannel 释放 Channel
class CustomActivity : FusionActivity(), FusionMessengerHandler {
override fun configureFlutterChannel(binaryMessenger: BinaryMessenger) {
val channel = MethodChannel(binaryMessenger, "custom_channel")
channel.setMethodCallHandler { call, result ->
}
}
override fun releaseFlutterChannel() {
channel?.setMethodCallHandler(null)
channel = null
}
}
iOS 侧
①、与容器无关的方法
在 AppDelegate 中进行注册
var channel: FlutterMethodChannel? = nil
if let binaryMessenger = Fusion.instance.defaultEngine?.binaryMessenger {
channel = FlutterMethodChannel(name: "custom_channel", binaryMessenger: binaryMessenger)
}
channel?.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
}
②、与容器相关的方法
在自实现的 FusionViewController 上实现 FusionMessengerHandler 协议,在协议方法中创建 Channel
class CustomViewController : FusionViewController, FusionMessengerHandler {
func configureFlutterChannel(binaryMessenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "custom_channel", binaryMessenger: binaryMessenger)
channel?.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
}
}
func releaseFlutterChannel() {
channel?.setMethodCallHandler(nil)
channel = nil
}
}
HarmonyOS 侧
①、与容器无关的方法
在 UIAbility 中进行注册
const binaryMessenger = Fusion.instance.defaultEngine?.dartExecutor.getBinaryMessenger()
const channel = new MethodChannel(binaryMessenger!, 'custom_channel')
channel.setMethodCallHandler({
onMethodCall(call: MethodCall, result: MethodResult): void {
}
})
②、与容器相关的方法
在自实现的 FusionEntry 上实现 FusionMessengerHandler 接口,在 configureFlutterChannel 中创建 Channel,在 releaseFlutterChannel 释放 Channel
export default class CustomFusionEntry extends FusionEntry implements FusionMessengerHandler, MethodCallHandler {
private channel: MethodChannel | null = null
configureFlutterChannel(binaryMessenger: BinaryMessenger): void {
this.channel = new MethodChannel(binaryMessenger, 'custom_channel')
this.channel.setMethodCallHandler(this)
}
onMethodCall(call: MethodCall, result: MethodResult): void {
result.success(`Custom Channel:${this}_${call.method}`)
}
releaseFlutterChannel(): void {
this.channel?.setMethodCallHandler(null)
this.channel = null
}
}
BasicMessageChannel 和 EventChannel 使用也是类似
P.S.: 与容器相关的方法是与容器生命周期绑定的,如果容器不可见或者销毁了则无法收到Channel消息。
6、生命周期
应用生命周期监听:
- ①、在 Flutter 侧任意处注册监听皆可,并
implements
FusionAppLifecycleListener - ②、根据实际情况决定是否需要注销监听
void main() {
...
FusionAppLifecycleBinding.instance.register(MyAppLifecycleListener());
runApp(const MyApp());
}
class MyAppLifecycleListener implements FusionAppLifecycleListener {
@override
void onBackground() {
print('onBackground');
}
@override
void onForeground() {
print('onForeground');
}
}
FusionAppLifecycleListener 生命周期回调函数:
- onForeground: 应用进入前台会被调用(首次启动不会被调用,Android 与 iOS 保持一致)
- onBackground: 应用退到后台会被调用
页面生命周期监听:
- ①、在需要监听生命周期页面的 State 中
implements
FusionPageLifecycleListener - ②、在 didChangeDependencies 中注册监听
- ③、在 dispose 中注销监听
class LifecyclePage extends StatefulWidget {
const LifecyclePage({Key? key}) : super(key: key);
@override
State<LifecyclePage> createState() => _LifecyclePageState();
}
class _LifecyclePageState extends State<LifecyclePage>
implements FusionPageLifecycleListener {
@override
Widget build(BuildContext context) {
return Container();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
FusionPageLifecycleBinding.instance.register(this);
}
@override
void onPageVisible() {}
@override
void onPageInvisible() {}
@override
void onForeground() {}
@override
void onBackground() {}
@override
void dispose() {
super.dispose();
FusionPageLifecycleBinding.instance.unregister(this);
}
}
PageLifecycleListener 生命周期回调函数:
- onForeground: 应用进入前台会被调用,所有注册了生命周期监听的页面都会收到
- onBackground: 应用退到后台会被调用,所有注册了生命周期监听的页面都会收到
- onPageVisible: 该 Flutter 页面可见时被调用,如:从 Native 页面或其他 Flutter 页面
push
到该 Flutter 页面时;从 Native 页面或其他 Flutter 页面pop
到该 Flutter 页面时;应用进入前台时也会被调用。 - onPageInvisible: 该 Flutter 页面不可见时被调用,如:从该 Flutter 页面
push
到 Native 页面或其他 Flutter 页面时;如从该 Flutter 页面pop
到 Native 页面或其他 Flutter 页面时;应用退到后台时也会被调用。
7、全局通信
支持消息在应用中的传递,可以指定 Native 还是 Flutter 或者全局接收和发送。
注册消息监听
Flutter侧
- ①、在需要监听消息的类中
implements
FusionNotificationListener,并复写onReceive
方法,该方法可收到发送过来的消息 - ②、在合适时机注册监听
- ③、在合适时机注销监听
class TestPage extends StatefulWidget {
@override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> implements FusionNotificationListener {
@override
void onReceive(String name, Map<String, dynamic>? body) {
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
FusionNotificationBinding.instance.register(this);
}
@override
void dispose() {
super.dispose();
FusionNotificationBinding.instance.unregister(this);
}
}
Native侧
- ①、在需要监听消息的类中实现 FusionNotificationListener 接口,并复写
onReceive
方法,该方法可收到发送过来的消息 - ②、在适当时机使用
FusionNotificationBinding
的register
方法注册监听 - ③、在适当时机使用
FusionNotificationBinding
的unregister
方法注销监听
发送消息
三端均可使用FusionNavigator
的 sendMessage
方法来发送消息,根据使用FusionNotificationType 不同类型有不同效果:
- flutter: 仅 Flutter 可以收到
- native: 仅 Native 可以收到
- global(默认): Flutter 和 Native 都可以收到
8、返回拦截
在纯 Flutter 开发中可以使用WillPopScope
组件拦截返回操作,Fusion 也完整支持该功能,使用方式与在纯 Flutter 开发完全一致,此外使用FusionNavigator.maybePop
的操作也可被WillPopScope
组件拦截。
9、状态恢复
Fusion 支持 Android 和 iOS 平台 APP 被回收后 Flutter 路由的恢复。
来源:juejin.cn/post/7329573765087019034
明明 3 行代码即可轻松实现,Promise 为何非要加塞新方法?
给前端以福利,给编程以复利。大家好,我是大家的林语冰。
00. 观前须知
地球人都知道,JS 中的异步编程是 单线程 的,和其他多线程语言的三观一龙一猪。因此,虽然其他语言的异步模式彼此互通有无,但对 JS 并不友好,比如 Actor 模型等。
这并不是说 JS 被异步社区孤立了,只是因为 JS 天生和多线程八字不合。你知道的,要求 JS 使用多线程,就像要求香菜恐惧症患者吃香菜一样离谱。本质上而言,这是刻在 JS 单线程 DNA 里的先天基因,直接决定了 JS 的“异步性状”。有趣的是,如今 JS 也变异出若干多线程的使用场景,只是比较非主流。
ES6 之后,JS 的异步编程主要基于 Promise
设计,比如人气爆棚的 fetch
API 等。因此,最新的 ES2024 功能里,又双叒叕往 Promise
加塞了新型静态方法 Promise.withResolvers()
,也就见怪不怪了。
问题在于,我发现这个新方法居然只要 3 行代码就能实现!奥卡姆剃刀原则告诉我们, 若无必要,勿增实体。那么这个鸡肋的新方法是否违背了奥卡姆剃刀原则呢?我决定先质疑、再质疑。
当然,作为应试教育的漏网之鱼,我很擅长批判性思考,不会被第一印象 PUA。经过三天三夜的刻意练习,机智如我发现新方法果然深藏不露。所以,本期我们就一起来深度学习 Promise
新方法的技术细节。
01. 静态工厂方法
Promise.withResolvers()
源自 tc39/proposal-promise-with-resolvers
提案,是 Promise
类新增的一个 静态工厂方法。
静态的意思是,该方法通过 Promise
类调用,而不是通过实例对象调用。工厂的意思是,我们可以使用该方法生成一个 Promise
实例,而无须求助于传统的构造函数 + new
实例化。
可以看到,这类似于 Promise.resolve()
等语法糖。区别在于,传统构造函数实例化的对象状态可能不太直观,而这里的 promise
显然处于待定状态,此外还“买一送二”,额外附赠一对用于改变 promise
状态的“变态函数” —— resolve()
和 reject()
。
ES2024 之后,该方法可以作为一道简单的异步笔试题 —— 请你在一杯泡面的时间里,实现一下 Promise.withResolvers()
。
如果你是我的粉丝,根本不慌,因为新方法的基本原理并不复杂,参考我下面的实现,简单给面试官表演一下就欧了。
可以看到,这个静态工厂方法的实现难点在于,如何巧妙地将变态函数暴露到外部作用域,其实核心逻辑压缩后有且仅有 3 行代码。
这就引发了本文开头的质疑:新方法是否多此一举?难道负责 JS 标准化的 tc39 委员会也有绩效考核,还是确实存在某些不为人知的极端情况?
02. 技术细节
通过对新方法进行苏格拉底式的“灵魂拷问”和三天三夜的深度学习,我可以很有把握地说,没人比我更懂它。
首先,与传统的构造函数实例化不同,新方法支持无参构造,我们不需要在调用时传递任何参数。
可以看到,构造函数实例化要求传递一个执行器回调,偷懒不传则直接报错,无法顺利实例化。
其次,变态函数的设计更加自由。
可以看到,传统的构造函数中,变态函数能且仅能作为局部变量使用,无法在构造函数外部调用。而新方法同时返回实例及其变态函数,这意味着实例和变态函数处于同一级别的作用域。
那么,这个设计上的小细节有何黑科技呢?
假设我们想要一个 Promise
实例,但尚未知晓异步任务的所有细节,我们期望先将变态函数抽离出来,再根据业务逻辑灵活调用,请问阁下如何应对?
ES2024 之前,我们可以通过 作用域提升 来“曲线救国”,举个栗子:
可以看到,这种方案的优势在于,诉诸作用域提升,我们不必把所有猫猫放在一个薛定谔的容器里,在构造函数中封装一大坨“代码屎山”;其次,变态函数不被限制在构造函数内部,随时随地任你调用。
该方案的缺陷则在于,某些社区规范鼓励“const
优先”的代码风格,即 const
声明优先,再按需修改为 let
声明。
这里的变态函数被迫使用 let
声明,这意味着存在被愣头青意外重写的隐患,但为了缓存赋值,我们一开始就不能使用 const
声明。从防御式编程的角度,这可能不太鲁棒。
因此,Promise.withResolvers()
应运而生,该静态工厂方法允许我们:
- 无参构造
const
优先- 自由变态
03. 设计动机
在某些需要封装 Promise
风格的场景中,新方法还能减少回调函数的嵌套,我把这种代码风格上的优化称为“去回调化”。
举个栗子,我们可以把 Node 中回调风格的 API 转换为 Promise
风格,以 fs
模块为例:
可以看到,由于使用了传统的构造函数实例化,在封装 readFile()
的时候,我们被迫将其嵌套在构造函数内部。
现在,我们可以使用新方法来“去回调化”。
可以看到,传统构造函数嵌套的一层回调函数就无了,整体实现更加扁平,减肥成功!
粉丝请注意,很多 Node API 现在也内置了 Promise
版本,现实开发中不需要我们手动封装,开箱即用就欧了。但是这种封装技巧是通用的。
举个栗子,瞄一眼 MDN 电子书搬运过来的一个更复杂的用例,将 Node 可读流转换为异步可迭代对象。
可以看到,井然有序的代码中透露着一丝无法形容的优雅。我脑补了一下如何使用传统构造函数来实现上述功能,现在还没缓过来......
04. 高潮总结
从历史来看,Promise.withResolvers()
并非首创,bluebird 的 Promise.defer()
或 jQuery 的 $.defer()
等库就提供了同款功能,ES2024 只是换了个名字“新瓶装旧酒”,将其标准化为内置功能。
但是,Promise.withResolvers()
的标准化势在必行,比如 Vite 源码中就自己手动封装了同款功能。
无独有偶,Axios、Vue、TS、React 等也都在源码内部“反复造轮子”,像这种回头率超高的代码片段我们称之为 boilerplate code(样板代码)。
重复乃编程之大忌,既然大家都要写,不如大家都别写,让 JS 自己写,牺牲小我,成全大家。编程里的 DRY 原则就是让我们不要重复,因为很多 bug 就是重复导致的,而且不好统一管理和维护,《ES6 标准入门教程》科普的 魔术字符串 就是其中一种反模式。
兼容性方面,我也做过临床测试了,主流浏览器广泛支持。
总之,Promise.withResolvers()
通过将样板代码标准化,达到了消除重复的目的,原生实现除了性能更好,是一个性价比较高的静态工厂方法。
参考文献
- GitHub:github.com/tc39/propos…
- MDN:developer.mozilla.org/en-US/docs/…
- bluebird:bluebirdjs.com/docs/deprec…
粉丝互动
本期话题是:你觉得新方法好评几颗星,为什么?你可以在本文下方自由言论,文明科普。
欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。
坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~
来源:juejin.cn/post/7391745629876469760
商品 sku 在库存影响下的选中与禁用
分享一下,最近使用 React 封装的一个 Skus 组件,主要用于处理商品的sku在受到库存的影响下,sku项的选中和禁用问题;
需求分析
需要展示商品各规格下的sku信息,以及根据该sku的库存是否为空,判断是否禁用该sku的选择。

以下讲解将按照我的 Skus组件 来,我这里放上我组件库中的线上 demo 和码上掘金的一个 demo 供大家体验;由于码上掘金导入不了组件库,我就上传了一份开发组件前的一份类似的代码,功能和代码思路是差不多的,大家也可以自己尝试写一下,可能你的思路会更优;
码上掘金
传入的sku数据结构
需要传入的商品的sku数据类型大致如下:
type SkusProps = {
/** 传入的skus数据列表 */
data: SkusItem[]
// ... 其他的props
}
type SkusItem = {
/** 库存 */
stock?: number;
/** 该sku下的所有参数 */
params: SkusItemParam[];
};
type SkusItemParam = {
name: string;
value: string;
}
转化成需要的数据类型:
type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];
生成数据
定义 sku 分类
首先假装请求接口,造一些假数据出来,我这里自定义了最多 6^6 = 46656
种 sku。

下面的是自定义的一些数据:
const skuData: Record<string, string[]> = {
'颜色': ['红','绿','蓝','黑','白','黄'],
'大小': ['S','M','L','XL','XXL','MAX'],
'款式': ['圆领','V领','条纹','渐变','轻薄','休闲'],
'面料': ['纯棉','涤纶','丝绸','蚕丝','麻','鹅绒'],
'群体': ['男','女','中性','童装','老年','青少年'],
'价位': ['<30','<50','<100','<300','<800','<1500'],
}
const skuNames = Object.keys(skuData)
页面初始化
- checkValArr: 需要展示的sku分类是哪些;
- skusList: 接口获取的skus数据;
- noStockSkus: 库存为零对应的skus(方便查看)。
export default () => {
// 这个是选中项对应的sku类型分别是哪几个。
const [checkValArr, setCheckValArr] = useState<number[]>([4, 5, 2, 3, 0, 0]);
// 接口请求到的skus数据
const [skusList, setSkusList] = useState<SkusItem[]>([]);
// 库存为零对应的sku数组
const [noStockSkus, setNoStockSkus] = useState<string[][]>([])
useEffect(() => {
const checkValTrueArr = checkValArr.filter(Boolean)
const _noStockSkus: string[][] = [[]]
const list = getSkusData(checkValTrueArr, _noStockSkus)
setSkusList(list)
setNoStockSkus([..._noStockSkus])
}, [checkValArr])
// ....
return <>...</>
}
根据上方的初始化sku数据,生成一一对应的sku,并随机生成对应sku的库存。
getSkusData 函数讲解
先看总数(total)为当前需要的各sku分类的乘积;比如这里就是上面传入的 checkValArr
数组 [4,5,2,3]
共 120种sku
选择。对应的就是 skuData
中的 [颜色前四项,大小前五项,款式前两项,面料前三项] 即下图的展示。

遍历 120 次,每次生成一个sku,并随机生成库存数量,40%的概率库存为0;然后遍历 skuNames
然后找到当前对应的sku分类即 [颜色,大小,款式,面料]
4项;
接下来就是较为关键的如何根据 sku的分类顺序
生成对应的 120个相应的sku。
请看下面代码中注释为 LHH-1
的地方,该 value 的获取是通过 indexArr
数组取出来的。可以看到上面 indexArr
数组的初始值为 [0,0,0,0]
4个零的索引,分别对应 4 个sku的分类;
- 第一次遍历:
indexArr: [0,0,0,0]
-> skuName.forEach -> 红,S,圆领,纯棉
看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,1]
;
- 第二次遍历:
indexArr: [0,0,0,1]
-> skuName.forEach -> 红,S,圆领,涤纶
看LHH-2标记处: 索引+1 -> indexArr: [0,0,0,2]
;
- 第三次遍历:
indexArr: [0,0,0,2]
-> skuName.forEach -> 红,S,圆领,丝绸
看LHH-2标记处: 由于已经到达该分类下的最后一个,所以前一个索引加一,后一个重新置为0 -> indexArr: [0,0,1,0]
;
- 第四次遍历:
indexArr: [0,0,1,0]
-> skuName.forEach -> 红,S,V领,纯棉
看LHH-2标记处: 索引+1 -> indexArr: [0,0,1,1]
;
- 接下来的一百多次遍历跟上面的遍历同理

function getSkusData(skuCategorys: number[], noStockSkus?: string[][]) {
// 最终生成的skus数据;
const skusList: SkusItem[] = []
// 对应 skuState 中各 sku ,主要用于下面遍历时,对 product 中 skus 的索引操作
const indexArr = Array.from({length: skuCategorys.length}, () => 0);
// 需要遍历的总次数
const total = skuCategorys.reduce((pre, cur) => pre * (cur || 1), 1)
for(let i = 1; i <= total; i++) {
const sku: SkusItem = {
// 库存:60%的几率为0-50,40%几率为0
stock: Math.floor(Math.random() * 10) >= 4 ? Math.floor(Math.random() * 50) : 0,
params: [],
}
// 生成每个 sku 对应的 params
let skuI = 0;
skuNames.forEach((name, j) => {
if(skuCategorys[j]) {
// 注意:LHH-1
const value = skuData[name][indexArr[skuI]]
sku.params.push({
name,
value,
})
skuI++;
}
})
skusList.push(sku)
// 注意: LHH-2
indexArr[indexArr.length - 1]++;
for(let j = indexArr.length - 1; j >= 0; j--) {
if(indexArr[j] >= skuCategorys[j] && j !== 0) {
indexArr[j - 1]++
indexArr[j] = 0
}
}
if(noStockSkus) {
if(!sku.stock) {
noStockSkus.at(-1)?.push(sku.params.map(p => p.value).join(' / '))
}
if(indexArr[0] === noStockSkus.length && noStockSkus.length < skuCategorys[0]) {
noStockSkus.push([])
}
}
}
return skusList
}
Skus 组件的核心部分的实现
初始化数据
需要将上面生成的数据转化为以下结构:
type SkuStateItem = {
value: string;
/** 与该sku搭配时,该禁用的sku组合 */
disabledSkus: string[][];
}[];
export default function Skus() {
// 转化成遍历判断用的数据类型
const [skuState, setSkuState] = useState<Record<string, SkuStateItem>>({});
// 当前选中的sku值
const [checkSkus, setCheckSkus] = useState<Record<string, string>>({});
// ...
}
将初始sku数据生成目标结构
根据 data (即上面的假数据)生成该数据结构。
第一次遍历是对skus第一项进行的,会生成如下结构:
const _skuState = {
'颜色': [{value: '红', disabledSkus: []}],
'大小': [{value: 'S', disabledSkus: []}],
'款式': [{value: '圆领', disabledSkus: []}],
'面料': [{value: '纯棉', disabledSkus: []}],
}
第二次遍历则会完整遍历剩下的skus数据,并往该对象中填充完整。
export default function Skus() {
// ...
useEffect(() => {
if(!data?.length) return
// 第一次对skus第一项的遍历
const _checkSkus: Record<string, string> = {}
const _skuState = data[0].params.reduce((pre, cur) => {
pre[cur.name] = [{value: cur.value, disabledSkus: []}]
_checkSkus[cur.name] = ''
return pre
}, {} as Record<string, SkuStateItem>)
setCheckSkus(_checkSkus)
// 第二次遍历
data.slice(1).forEach(item => {
const skuParams = item.params
skuParams.forEach((p, i) => {
// 当前 params 不在 _skuState 中
if(!_skuState[p.name]?.find(params => params.value === p.value)) {
_skuState[p.name].push({value: p.value, disabledSkus: []})
}
})
})
// ...接下面
}, [data])
}
第三次遍历主要用于为每个 sku的可点击项
生成一个对应的禁用sku数组 disabledSkus
,只要当前选择的sku项,满足该数组中的任一项,该sku选项就会被禁用。之所以保存这样的一个二维数组,是为了方便后面点击时的条件判断(有点空间换时间的概念)。
遍历 data 当库存小于等于0时,将当前的sku的所有参数传入 disabledSkus
中。
例:第一项 sku(红,S,圆领,纯棉)库存假设为0,则该选项会被添加到 disabledSkus
数组中,那么该sku选择时,勾选前三个后,第四个 纯棉
的勾选会被禁用。

export default function Skus() {
// ...
useEffect(() => {
// ... 接上面
// 第三次遍历
data.forEach(sku => {
// 遍历获取库存需要禁用的sku
const stock = sku.stock!
// stockLimitValue 是一个传参 代表库存的限制值,默认为0
// isStockGreaterThan 是一个传参,用来判断限制值是大于还是小于,默认为false
if(
typeof stock === 'number' &&
isStockGreaterThan ? stock >= stockLimitValue : stock <= stockLimitValue
) {
const curSkuArr = sku.params.map(p => p.value)
for(const name in _skuState) {
const curSkuItem = _skuState[name].find(v => curSkuArr.includes(v.value))
curSkuItem?.disabledSkus?.push(
sku.params.reduce((pre, p) => {
if(p.name !== name) {
pre.push(p.value)
}
return pre
}, [] as string[])
)
}
}
})
setSkuState(_skuState)
}, [data])
}
遍历渲染 skus 列表
根据上面的 skuState
,生成用于渲染的列表,渲染列表的类型如下:
type RenderSkuItem = {
name: string;
values: RenderSkuItemValue[];
}
type RenderSkuItemValue = {
/** sku的值 */
value: string;
/** 选中状态 */
isChecked: boolean
/** 禁用状态 */
disabled: boolean;
}
export default function Skus() {
// ...
/** 用于渲染的列表 */
const list: RenderSkuItem[] = []
for(const name in skuState) {
list.push({
name,
values: skuState[name].map(sku => {
const isChecked = sku.value === checkSkus[name]
const disabled = isChecked ? false : isSkuDisable(name, sku)
return { value: sku.value, disabled, isChecked }
})
})
}
// ...
}
html css 大家都会,以下就简单展示了。最外层遍历sku的分类,第二次遍历遍历每个sku分类下的名称,第二次遍历的 item(类型为:RenderSkuItemValue
),里面会有sku的值,选中状态和禁用状态的属性。
export default function Skus() {
// ...
return list?.map((p) => (
<div key={p.name}>
{/* 例:颜色、大小、款式、面料 */}
<div>{p.name}</div>
<div>
{p.values.map((sku) => (
<div
key={p.name + sku.value}
onClick={() => selectSkus(p.name, sku)}
>
{/* classBem 是用来判断当前状态,增加类名的一个方法而已 */}
<span className={classBem(`sku`, {active: sku.isChecked, disabled: sku.disabled})}>
{/* 例:红、绿、蓝、黑 */}
{sku.value}
</span>
</div>
))}
</div>
</div>
))
}
selectSkus 点击选择 sku
通过 checkSkus
设置 sku 对应分类下的 sku 选中项,同时触发 onChange
给父组件传递一些信息出去。
const selectSkus = (skuName: string, {value, disabled, isChecked}: RenderSkuItemValue) => {
const _checkSkus = {...checkSkus}
_checkSkus[skuName] = isChecked ? '' : value;
const curSkuItem = getCurSkuItem(_checkSkus)
// 该方法主要是 sku 组件点击后触发的回调,用于给父组件获取到一些信息。
onChange?.(_checkSkus, {
skuName,
value,
disabled,
isChecked: disabled ? false : !isChecked,
dataItem: curSkuItem,
stock: curSkuItem?.stock
})
if(!disabled) {
setCheckSkus(_checkSkus)
}
}
getCurSkuItem 获取当前选中的是哪个sku
isInOrder.current
是用来判断当前的 skus 数据是否是整齐排列的,这里当成true
就好,判断该值的过程就不放到本文了,感兴趣可以看 源码。
由于sku是按顺序排列的,所以只需按顺序遍历上面生成的 skuState
,找出当前sku选中项对应的索引位置,然后通过 乘
就可以直接得出对应的索引位置。这样的好处是能减少很多次遍历。
如果直接遍历原来那份填充所有 sku 的 data
数据,则需要很多次的遍历,当sku是 6^6
时, 则每次变换选中的sku时最多需要 46656 * 6
(data总长度 * 里面 sku 的 params) 次。
const getCurSkuItem = (_checkSkus: Record<string, string>) => {
const length = Object.keys(skuState).length
if(!length || Object.values(_checkSkus).filter(Boolean).length < length) return void 0
if(isInOrder.current) {
let skuI = 0;
// 由于sku是按顺序排列的,所以索引可以通过计算得出
Object.keys(_checkSkus).forEach((name, i) => {
const index = skuState[name].findIndex(v => v.value === _checkSkus[name])
const othTotal = Object.values(skuState).slice(i + 1).reduce((pre, cur) => (pre *= cur.length), 1)
skuI += index * othTotal;
})
return data?.[skuI]
}
// 这样需要遍历太多次
return data.find(s => (
s.params.every(p => _checkSkus[p.name] === getSkuParamValue(p))
))
}
isSkuDisable 判断该 sku 是否是禁用的
该方法是在上面 遍历渲染 skus 列表
时使用的。
- 开始还未有选中值时,需要校验 disabledSkus 的数组长度,是否等于该sku参数可以组合的sku总数,如果相等则表示禁用。
- 判断当前选中的 sku 还能组成多少种组合。例:当前选中
红,S
,而isSkuDisable
方法当前判断的 sku 为款式 中的 圆领
,则还有三种组合红\S\圆领\纯棉
,红\S\圆领\涤纶
和红\S\圆领\丝绸
。 - 如果当前判断的 sku 的
disabledSkus
数组中存在这三项,则表示该 sku 选项会被禁用,无法点击。
const isCheckValue = !!Object.keys(checkSkus).length
const isSkuDisable = (skuName: string, sku: SkuStateItem[number]) => {
if(!sku.disabledSkus.length) return false
// 1.当一开始没有选中值时,判断某个sku是否为禁用
if(!isCheckValue) {
let checkTotal = 1;
for(const name in skuState) {
if(name !== skuName) {
checkTotal *= skuState[name].length
}
}
return sku.disabledSkus.length === checkTotal
}
// 排除当前的传入的 sku 那一行
const newCheckSkus: Record<string, string> = {...checkSkus}
delete newCheckSkus[skuName]
// 2.当前选中的 sku 一共能有多少种组合
let total = 1;
for(const name in newCheckSkus) {
if(!newCheckSkus[name]) {
total *= skuState[name].length
}
}
// 3.选中的 sku 在禁用数组中有多少组
let num = 0;
for(const strArr of sku.disabledSkus) {
if(Object.values(newCheckSkus).every(str => !str ? true : strArr.includes(str))) {
num++;
}
}
return num === total
}
至此整个商品sku从生成假数据到sku的选中和禁用的处理的核心代码就完毕了。还有更多的细节问题可以直接查看 源码 会更清晰。
来源:juejin.cn/post/7313979106890842139
筑牢湾区网络安全防线!Coremail亮相大湾区网络安全大会
11月7-8日,大湾区网络安全大会在广州隆重举行。Coremail作为邮件行业领导者,受邀参会并亮相多场论坛,与现场嘉宾围绕网络安全的前沿话题与挑战展开深入交流与探讨。
本次大会以“共建网络安全,对话数字未来”为主题,聚焦信息技术应用创新、人工智能攻防对抗技术、AI+关键信息基础设施保护、数字安全创新等热点话题。通过多场行业论坛与主题演讲,全面展示网络安全技术的最新成果与未来趋势,激发参会者的创新思维,共同推动网络安全技术的持续进步与创新发展。
汇聚新动能 彰显邮件创新硬实力
世界经济数字化转型是大势所趋,人工智能、区块链、5G等新技术广泛应用,对邮件领域有着怎么样的启发和新尝试?
本届大会上,Coremail重点展示了邮件系统国产化实践与创新成果,以及AI+邮件的创新应用。通过多媒体展示互动与现场讲解直观、全面地为与会者呈现了Coremail在邮件领域的硬实力和新案例。
在大会的评选活动中,CACTER邮件安全网关解决方案不负众望,荣获“信息网络安全建设优秀案例”奖项。
解构新趋势探索邮件安全新篇章
11月7日,2024年大湾区信息技术应用创新产业发展论坛大幕拉开,Coremail副总裁吴秀诚以“信创环境下邮件数据安全的探索与实践”为主题,分享了Coremail在信创邮件升级和数据安全防护方面的探索经验和先进产品。
近年来,数据安全越来越被重视,而邮件系统承载着大量数据的内外往来,是企业至关重要的基础设施。据Coremail与奇安信联合发布的《2023中国企业邮箱安全性研究报告》数据显示,2023年国内共收发各类电子邮件约7798.5亿封,其中垃圾邮件占比54.2%。随着AI技术的广泛应用,网络安全问题更加多样化,攻击者正利用AI使钓鱼邮件变得更加丰富和逼真,严重威胁数据安全。
△数据显示,2023年基于 AI 的攻击和诈骗邮件增长了1000%。在2024年第一季度中,企业邮件攻击和诈骗邮件数量同比增长59.9%
Coremail 25年来一直致力于邮件及邮件安全领域的技术研究与创新,为各行业用户提供综合的整体电子邮件安全解决方案,目前在我国的邮件终端使用用户量超10亿。作为国内安全邮件的先行者,Coremail积极相应《网络数据安全管理条例》,推出首款覆盖网络安全保险保障的云邮箱产品,覆盖多种常见邮件安全风险领域的保障。在安全防护层面,Coremail基于多年的邮件系统研发、服务经验,提供全面、多机制的保护策略,构建AI安全防御体系。
另一方面,Coremail也在积极探索AI赋能高效办公,推出AI大模型整合方案,将邮箱能力解耦调用,以邮箱桥接大模型,实现智能化和自动化。
聚焦AI+把脉邮件安全新态势
11月8日,网络与数据安全分论坛开讲,Coremail高级安全解决方案专家刘骞发表“拥抱AI:探索AI大模型在邮件反钓鱼领域中的应用潜力”主题演讲,分享了Coremail在AI大模型融合邮件防护应用的探索。
电子邮件作为日常工作和商务沟通的重要工具,其安全性直接关系到企业和个人的利益。然而,邮件系统面临着内部泄密、外部攻击等多种安全威胁,对于不同威胁场景,Coremail针对性提供了不同防护策略,以保障邮件使用安全。
近几年,AI成为行业顶流,Coremail CACTER邮件安全人工智能实验室也在不断探索新技术在邮件安全防护中的可能性,通过深入研究与实践,发现AI在反钓鱼领域多个场景中均能发挥其优势。今年,Coremail AI实验室引入清华智谱ChatGLM大语言模型,进一步提升钓鱼邮件检测能力。
与文本大模型相比,多模态大模型能够处理更丰富的信息数据源,如文本、图像、音频等,不仅能进行文本理解,还能模拟视觉分析,处理图片和链接落地页等多媒体内容,为钓鱼检测提供更全面的支持。CACTER AI实验室正积极探索多模态大模型在邮件安全领域的应用,以进一步提升对钓鱼邮件的识别率和对新型攻击手段的适应性。
当前,人工智能正与千行百业深度融合,成为社会及经济结构革新的关键支柱。Coremail将持续深化邮件技术的自主创新和数智化转型,探索更多新技术与邮件的有机融合,为各行业数字化升级赋能!
收起阅读 »如果你没有必须要离职的原因,我建议你在忍忍
自述
本人成都,由于一些原因我在八月离职了,因为我终于脱离了那个压抑的环境,我没有自己想象中的那么开心,我离职的那天,甚至后面很长的一段时间;离职后的我回了一趟家,刚好在最热的那几天,在家躺了几天,然后又出去逛了逛,玩了差不多一个月吧!我觉得心情逐渐恢复了;然后开始慢慢的投递简历。
前期
刚投递简历那会,基本上每天都是耍耍哒哒的投递;有面试就去面试,没有面试就在家刷抖音也不看看面试题,可能我找工作的状态还在几年前或者还没从上家公司的状态中走出来,也有可能我目前有一点存款不是特别焦虑,所以也没认真的找。
就这样刷刷哒哒的又过了半月,然后有许多朋友跟我说他们被裁员了,问他们的打算是怎么样的:有的人休息了两三天就开始了找工作当中,而有的人就玩几个月再说。
休息两三天就开始找工作的人基本上都是有家庭有小孩的,反之基本上都是单身。
在跟他们聊天的过程中发现,有些人半年没找到工作了,也有一些人一年都没有找到工作了。可能是年级大了、也可能是工资不想要的太低吧!但是工作机会确实比原来少很多。
在听了大家的话以后,我觉得我差不多也该认真找工作了,我开始逐渐投递简历。
疯狂投递简历
我在9月的下旬开始了简历的修改以及各大招聘App的下载,拉钩、智联、boos以及一下小程序的招聘软件(记不住名字了,因为没啥效果);在我疯狂的投递了几天以后我迎来了第一家面试,是一个线上面试;刚一来就给了我迎头一棒,或许我只忙着修改简历和投递简历去了,没有去背面试题吧(网上说现在都问场景题,所以没准备);
具体的问题我记不全了,但是我记得这么一个问题,面试官问:“深克隆有哪些方法”,我回答的是递归,然后他说还有吗?我直接呆住说不知道了。然后我们就结束了面试,最后他跟我说了这么一句话:“现在的市场行情跟原来没法比,现在的中级基本上要原来的高级的水平,现在的初级也就是原来的中级的水平,所以问的问题会比原来难很多,你可以在学习一下,看你的简历是很不错的;至少简历是这样的。”
当这个面试结束以后我想了想发现是这样的,不知是我还没有接受或者说还没有进入一个面试的状态,还是因为我不想上班的原因,导致我连一些基本的八股文都不清楚,所以我决定开始学习。
给准备离职或者已经离职的朋友们一个忠告:“做任何事情都需提前准备,至少在找工作上是这样的。”
学习
我去看了招聘网站的技术要求(想了解下企业需要的一些技术),不看不知道一看吓一跳,真的奇葩层出不穷,大概给大家概述一下:
- 开发三班倒:分为早中晚班
- 要你会vue+react+php+java等技术(工资8-12)
- 要你会基本的绘画(UI)以及会后端的一些工作,目前这些都需要你一个人完成
- 要你会vue+react+fluter;了解electron以及3d等
- 还有就是你的项目跟我们的项目不一致的。
我看到这些稀奇古怪的玩意有点失望,最终我选择了fabricjs进行学习,最开始的时候就是在canvas上画了几个矩形,感觉挺不错的;然后我就想这不是马上快要国庆了吗?我就想用fabric做一个制作头像的这么一个工具插件,在经过两天的开发成功将其制作了出来,并且发布到了网站上(插件tools),发布第一天就有使用的小伙伴给我提一些宝贵的建议了,然后又开始了调整,现在功能也越来越多;
fabricjs在国内的资料很少,基本上就那么几篇文章,没有办法的我就跑去扒拉他们的源码看,然后拷贝需要的代码在修修改改(毕竟比较菜只能这样....);然后在学习fabric的时候也会去学习一些基本知识,比如:js内置方法、手写防抖节流、eventloop、闭包(一些原理逻辑)、深拷贝、内存回收机制等等。
在学习的过程中很难受,感觉每天都是煎熬;每次都想在床上躺着,但是想想还是放弃了,毕竟没有谁会喜欢一个懒惰的人...
在战面试(HR像是刷KPI)
在有所准备的情况下再去面试时就得心应手了,基本上没有太多的胆怯,基本上问啥都知道一些,然后就在面试的时候随机应变即可,10月我基本上接到的面试邀请大概有10多家,然后有几家感觉工资低了就没去面试,去面试了的应该有7/8家的样子,最终只要一家录取。
说说其中一家吧(很像刷KPI的一家):这是一家做ai相关的公司,公司很大,看资料显示时一家中外合资的企业,进去以后先开始了一轮笔试题(3/4页纸),我大概做了50分钟的样子;我基本上8层都答对了(因为他的笔试题很多我都知道嘛,然后有一些还写了几个解决方案的),笔试完了以后,叫我去机试;机试写接口;而且还是在规定的一个网站写(就给我一个网站,然后说写一个接口返回正确结果就行;那个网站我都不会用);我在哪儿磨磨蹭蹭了10多分钟以后,根据node写了一个接口给了hr;然后HR说你这个在我们网站上不能运行。我站起来就走了...
其实我走的原因还有一个,就是他们另一个HR对带我进来的这个HR说:你都没有协调好研发是否有时间,就到处招面试...
是否离职
如果你在你现在这公司还能呆下去的情况下,我建议你还是先呆呆看吧!目前这个市场行情很差,你看到我有10来个面试,但是你知道嘛?我沟通了多少:
- boos沟通了差不多800-900家公司,邀请我投递简历的只有100家左右。邀请我面试的只有8/9家。
- 智联招聘我投递了400-600家,邀请我面试的只有1家。
- 拉钩这个不说了基本上没有招聘的公司(反反复复就那几家);投递了一个月后有一家叫我去面试的,面试了差不多50来分钟;交谈的很开心,他说周一周二给我回复,结果没有回复,我发消息问;也没有回复;看招聘信息发现(邀约面试800+)
我离职情非得已,愿诸君与我不同;如若您已离职,愿您早日找到属于自己的路,不一定是打工的路;若你在职,请在坚持坚持;在坚持的同时去做一些对未来有用的事情,比如:副业、耍个男女朋友、拓展一下圈子等等。
后续的规划
在经历了这次离职以后,我觉得我的人生应该进行好好的规划了;不能为原有的事物所影响,不能为过去所迷茫;未来还很长,望诸君互勉;
未来的计划大致分为几个方向:
- 拓展自己的圈子(早日脱单)
- 学习开发鸿蒙(我已经在做了,目前开发的app在审核),发布几款工具类app(也算是为国内唯一的系统贡献一些微弱的力量吧!)
- 持续更新我在utools上的绘图插件
- 学习投资理财(最近一月炒股:目前赚了4000多了)
- 持续更新公众号(前端雾恋)、掘金等网站技术文章
结尾
我们的生活终将回归正轨,所有的昨天也将是历史,不必遗憾昨天,吸取教训继续前进。再见了...
来源:juejin.cn/post/7435289649273569334
轻量级Nacos来了!占用资源极低,性能炸裂!
Nacos作为一款非常流行的微服务注册中心,我们在构建微服务项目时往往会使用到它。最近发现一款轻量级的Nacos项目r-nacos,占用内存极低,性能也很强大,分享给大家。本文就以我的mall-swarm微服务电商实战项目为例,来聊聊它在项目中的使用。
r-nacos简介
r-nacos是一款使用rust实现的nacos服务,对比阿里的nacos来说,可以提供相同的注册中心和配置中心功能。同时它占用的内存更小,性能也很优秀,能提供更稳定的服务。
下面是r-nacos管理控制台使用的效果图,大家可以参考下:
mall-swarm项目简介
由于之后我们需要用到mall-swarm项目,这里简单介绍下它。 mall-swarm项目(11k+star)是一套微服务商城系统,基于2024最新微服技术栈,涵盖Spring Cloud Alibaba、Spring Boot 3.2、JDK17、Kubernetes等核心技术。mall-swarm在电商业务的基础集成了注册中心、配置中心、监控中心、网关等系统功能。
- Github地址:github.com/macrozheng/…
- Gitee地址:gitee.com/macrozheng/…
- 教程网站:cloud.macrozheng.com
项目演示:
安装
r-nacos支持Windows下的exe文件安装,也支持Linux下的Docker环境安装,这里以Docker安装为例。
- 首先通过如下命令下载r-nacos的Docker镜像:
docker pull qingpan/rnacos:stable
- 安装完成后通过如下命令运行r-nacos容器;
docker run --name rnacos -p 8848:8848 -p 9848:9848 -p 10848:10848 -d qingpan/rnacos:stable
- 接下来就可以访问r-nacos的控制台了,默认管理员账号密码为
admin:admin
,访问地址:http://192.168.3.101:10848/rnacos/
项目实战
接下来就以我的mall-swarm微服务电商实战项目为例,来讲解下它的使用。由于mall-swarm项目中各个服务的配置与运行都差不多,这里以mall-admin模块为例。
- 首先我们需要下载mall-swarm项目的代码,下载完成后修改项目的
bootstrap-dev.yml
文件,将其中的nacos连接地址改为r-nacos的地址,项目地址:github.com/macrozheng/…
spring:
cloud:
nacos:
discovery:
server-addr: http://192.168.3.101:8848
config:
server-addr: http://192.168.3.101:8848
file-extension: yaml
- 接下来在r-nacos的
配置列表
中添加mall-admin-dev.yaml
配置,该配置下项目的config
目录下;
- 之后把mall-admin模块运行起来,此时在
r-nacos
的服务列表
功能中就可以看到注册好的服务了;
- 接下来把其他模块的配置也添加到r-nacos的配置列表中去;
- 再运行其他模块,最终
服务列表
显示如下;
- 之后通过网关就可以访问到mall-swarm项目的在线API文档了,访问地址:http://localhost:8201/doc.html
- 这里我们再把mall-swarm项目的后台管理系统前端项目
mall-admin-web
给运行起来;
- 最后我们再把mall-swarm项目的前台商城系统前端项目
mall-app-web
给运行起来,发现都是可以正常从网关调用API的。
其他使用
r-nacos除了提供了基本的注册中心和配置中心功能,还提供了一些其他的实用功能,这里我们一起来了解下。
- 如果你想添加一些其他访问的用户,或者修改admin用户的信息,可以使用
用户管理
功能;
- 如果你想对r-nacos中配置信息进行导入导出,可以使用
数据迁移
功能;
- 如果你想对r-nacos中的运行状态进行监控,你可以使用
系统监控
功能,监控还是挺全的。
性能压测
r-nacos的性能还是非常好的,这里有个r-nacos官方提供的性能压测结果表,大家可以参考下。
对比Nacos
个人感觉对比阿里的nacos,占用的内存资源减少了非常多,运行不到10M内存,而nacos需要900M,服务器资源不宽裕的小伙伴可以尝试下它。
总结
今天以我的mall-swarm微服务电商实战项目为例,讲解了r-nacos的使用。从功能上来说r-nacos是完全可以替代nacos的,而且它占用内存资源非常低,性能也很强大,感兴趣的小伙伴可以尝试下它!
项目地址
来源:juejin.cn/post/7434185097300475919
既生@Primary,何生@Fallback
个人公众号:IT周瑜,十二年Java开发和架构经验,一年大模型应用开发经验,爱好研究源码,比如Spring全家桶源码、MySQL源码等,同时也喜欢分享技术干货,期待你的关注
最近闲着的时候在看Spring 6.2的源码,发现了一些新特性,比如本文要介绍的@Fallback注解。
相信大家都知道@Primary注解,而@Fallback相当于是@Primary的反向补充。
Spring在进行依赖注入时,会根据属性的类型去Spring容器中匹配Bean,但有可能根据类型找到了多个Bean,并且也无法根据属性名匹配到Bean时,就会报错,比如expected single matching bean but found 2,此时,就可以利用@Primary来解决。
加了@Primary的Bean表示是同类型多个Bean中的主Bean,换句话说,如果Spring根据类型找到了多个Bean,会选择其中加了@Primary的Bean来进行注入,因此,同类型的多个Bean中只能有一个加了@Primary,如果有多个也会报错more than one 'primary' bean found。
比如以下代码会使用orderService1来进行注入:
@Bean
@Primary
public OrderService orderService1() {
return new OrderService();
}
@Bean
public OrderService orderService2() {
return new OrderService();
}
而加了@Fallback注解的Bean为备选Bean,比如以下代码会使用orderService2来进行依赖注入:
@Bean
@Fallback
public OrderService orderService1() {
return new OrderService();
}
@Bean
public OrderService orderService2() {
return new OrderService();
}
因为orderService1加了@Fallback注解,相当于备胎,只有当没有其他Bean可用时,才会用orderService1这个备胎,有其他Bean就会优先用其他Bean。
@Primary和@Fallback都是用在依赖注入时根据类型找到了多个Bean的场景中:
- @Primary比较强势,它在说:“直接用我就可以了,不用管其他Bean”
- @Fallback比较弱势,它在说:“先别用我,先用其他Bean”
如果根据类型只找到一个Bean就用不着他两了,另外,同类型多个Bean中@Primary的Bean只能有一个,但可以有多个@Fallback。
大家觉得@Fallback注解怎么样?
实际上,@Primary和@Fallback两个注解的源码实现在同一个方法中,源码及注释如下,感兴趣的同学可以看看:
protected String determinePrimaryCandidate(Map<String, Object> candidates, Class<?> requiredType) {
String primaryBeanName = null;
// First pass: identify unique primary candidate
// 先找@Primary注解的Bean
// candidates就是根据类型找到的多个Bean,key为beanName, Value为bean对象
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateBeanName = entry.getKey();
Object beanInstance = entry.getValue();
if (isPrimary(candidateBeanName, beanInstance)) {
if (primaryBeanName != null) {
boolean candidateLocal = containsBeanDefinition(candidateBeanName);
boolean primaryLocal = containsBeanDefinition(primaryBeanName);
// 找到多个@Primary会报错
if (candidateLocal == primaryLocal) {
throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
"more than one 'primary' bean found among candidates: " + candidates.keySet());
}
else if (candidateLocal) {
primaryBeanName = candidateBeanName;
}
}
else {
// 找到一个@Primary注解的Bean就先存着,看是不是还有其他@Primay注解的Bean
primaryBeanName = candidateBeanName;
}
}
}
// Second pass: identify unique non-fallback candidate
// 没有@Primary注解的Bean情况下,才找没有加@Fallback注解的,加了@Fallback注解的Bean会被过滤掉
if (primaryBeanName == null) {
for (String candidateBeanName : candidates.keySet()) {
// 判断是否没有加@Fallback
if (!isFallback(candidateBeanName)) {
// 如果有多个Bean没有加@Fallback,会返回null,后续会根据属性名从多个bean中进行匹配,匹配不到就会报错
if (primaryBeanName != null) {
return null;
}
primaryBeanName = candidateBeanName;
}
}
}
return primaryBeanName;
}
来源:juejin.cn/post/7393311009686192147
Java程序员必知的9个SQL优化技巧
大多数的接口性能问题,很多情况下都是SQL问题,在工作中,我们也会定期对慢SQL进行优化,以提高接口性能。这里总结一下常见的优化方向和策略。
避免使用select *,减少查询字段
不要为了图省事,直接查询全部的字段,尽量查需要的字段,特别是复杂的SQL,能够避免很多不走索引的情况。这也是最基本的方法。
检查执行计划,是否走索引
检查where和order by字段是否有索引,根据表的数据量和现有索引,考虑是否增加索引或者联合索引。 然而,索引并不是越多越好,原因有以下几点:
- 存储空间:每个索引都会占用额外的存储空间。如果为表中的每一列都创建索引,那么这些索引的存储开销可能会非常大,尤其是在大数据集上。
- 索引重建增加开销:当数据发生变更(如插入、更新或删除)时,相关的索引也需要进行更新,以确保数据的准确性和查询效率。这意味着更多的索引会导致更慢的写操作。
- 选择性:选择性是指索引列中不同值的数量与表中记录数的比率。选择性高的列(即列中有很多唯一的值)更适合创建索引。对于选择性低的列(如性别列,其中只有“男”和“女”两个值),创建索引可能不会产生太大的查询性能提升。
- 过度索引:当表中存在过多的索引时,可能会导致数据库优化器在选择使用哪个索引时变得困难。这可能会导致查询性能下降,因为优化器可能选择了不是最优的索引。
因此,在设计数据库时,需要根据查询需求和数据变更模式来仔细选择需要创建索引的列。通常建议只为经常用于查询条件、排序和连接的列创建索引,并避免为选择性低的列创建索引。
避免使用or连接
假设我们有一个数据表employee,包含以下字段:id, name, age。 原始查询使用OR操作符来筛选满足name为'John'或age为30的员工:
SELECT * FROM employee WHERE name = 'John' OR age = 30;
使用UNION操作符来实现同样的筛选:
SELECT * FROM employee WHERE name = 'John'
UNION
SELECT * FROM employee WHERE age = 30;
UNION操作符先查询满足name为'John'的记录,然后查询满足age为30的记录,并将两个结果集合并起来。这样可以减少查询的数据量,提高查询效率。 需要注意的是,UNION操作符会去除重复的记录。如果想要保留重复的记录,可以使用UNION ALL操作符,例如: 判断两条记录是否为重复记录的标准是通过比较每个字段的值来确定的。
SELECT * FROM employee WHERE name = 'John'
UNION ALL
SELECT * FROM employee WHERE age = 30;
在使用UNION代替OR时,还需要注意查询语句的语义是否与原始查询相同。有些情况下,OR可能会产生更准确的结果,因此在使用UNION时需谨慎考虑语义问题。
减少in和not in的使用
说实话,这种情况有点难。实际工作中,使用in的场景很多,但是要尽量避免in后面的数据范围,范围太大的时候,要考虑分批处理等操作。
对于连续的数值,可以考虑使用between and 代替。
避免使用左模糊查询
在工作中,对于姓名、手机号、名称等内容,经常会遇到模糊查询的场景,但是要尽量避免左模糊,这种SQL无法使用索引。
- 左模糊查询: 假设我们有一个数据表customer,包含字段name,我们想要查询名字以"J"开头的客户:
SELECT * FROM customer WHERE name LIKE 'J%';
- 右模糊查询: 继续使用上述customer表,我们想要查询名字以"n"结尾的客户:
SELECT * FROM customer WHERE name LIKE '%n';
注意,在某些数据库中,对于右模糊查询,可能需要使用转义符号(如""),以防止通配符被误解。
- 全模糊查询: 还是使用上述customer表,我们想要查询名字中包含"son"的客户:
SELECT * FROM customer WHERE name LIKE '%son%';
连接查询join替代子查询
假设我们有两个表:订单表(orders)和客户表(customers)。 订单表包含了订单号(order_id)、客户ID(customer_id)和订单金额(amount),而客户表包含了客户ID(customer_id)和客户姓名(customer_name)。
我们要找出所有订单金额大于1000美元的客户姓名:
SELECT customer_name
FROM customers
WHERE customer_id IN (SELECT DISTINCT customer_id FROM orders WHERE amount > 1000);
以上查询首先在订单表中挑选出所有金额大于1000美元的客户ID,然后使用这个子查询的结果来过滤客户表并获取客户姓名。
使用 JOIN 来替代子查询的方式:
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 1000;
改造后的查询通过使用 INNER JOIN 将客户表和订单表连接在一起,然后使用 WHERE 子句来过滤出金额大于1000美元的订单。
这种改造不仅使查询更加简洁,而且可能还会提高查询的性能。JOIN 操作通常比子查询的效率更高,特别是在处理大型数据集时。
join的优化
JOIN 是 SQL 查询中的一个操作,用于将两个或多个表连接在一起。JOIN 操作有几种类型,包括 LEFT JOIN、RIGHT JOIN 和 INNER JOIN。要选用正确的关联方式,确保查询内容的正确性。
- INNER JOIN(内连接):内连接返回满足连接条件的行,即两个表中相关联的行组合。只有在两个表中都存在匹配的行时,才会返回结果。
SELECT *
FROM table1
INNER JOIN table2 ON table1.column = table2.column;
- LEFT JOIN(左连接):左连接返回左侧表中的所有行,以及右侧表中满足连接条件的行。如果右表中没有匹配的行,则返回 NULL 值。在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。
SELECT *
FROM table1
LEFT JOIN table2 ON table1.column = table2.column;
- RIGHT JOIN(右连接):右连接返回右侧表中的所有行,以及左侧表中满足连接条件的行。如果左表中没有匹配的行,则返回 NULL 值。
SELECT *
FROM table1
RIGHT JOIN table2 ON table1.column = table2.column;
需要注意的是,LEFT JOIN 和 RIGHT JOIN 是对称的,只是左右表的位置不同。INNER JOIN 则是返回共同匹配的行。
这些不同类型的 JOIN 可以灵活地根据查询需求选择使用。INNER JOIN 用于获取两个表中的匹配行,LEFT JOIN 和 RIGHT JOIN 用于获取一个表中的所有行以及另一个表中的匹配行。使用 JOIN 可以将多个表连接在一起,使我们能够根据关联的列获取相关的数据,并更有效地处理复杂的查询需求。但是使用的时候要特别注意,左右表的关联关系,是一对一、一对多还是多对多,对查询的结果影响很大。
gr0up by 字段优化
假设我们要计算每个客户的订单总金额,原始的查询可能如下所示:
SELECT customer_id, SUM(amount) AS total_amount
FROM orders
GR0UP BY customer_id;
在这个查询中,我们使用 GR0UP BY 字段 customer_id 对订单进行分组,并使用 SUM 函数计算每个客户的订单总金额。
为了优化这个查询,我们可以考虑以下几种方法:
- 索引优化:
- 确保在 customer_id 字段上创建索引,以加速 GR0UP BY 和 WHERE 子句的执行。
- 如果查询还包含其他需要的字段,可以考虑创建聚簇索引,将相关的字段放在同一个索引中,以减少查询的IO操作。
- 使用覆盖索引:
- 如果查询中只需要使用 customer_id 和 amount 两个字段,可以创建一个覆盖索引,它包含了这两个字段,减少了查找其他字段的开销。
- 子查询优化:
- 如果订单表很大,可以先使用子查询将数据限制在一个较小的子集上,然后再进行 GR0UP BY 操作。例如,可以先筛选出最近一段时间的订单,然后再对这些订单进行分组。
- 条件优化:
- 使用WHERE条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。而不是在分组后使用having过滤数据。
深分页limit优化
深分页通常指的是在处理大量数据时,用户需要浏览远离首页的页面,例如第100页、第1000页等。这种场景下,如果简单地一次性加载所有数据并进行分页,会导致性能问题,包括内存消耗、数据库查询效率等。
我们日常使用较多的分页一般是用的PageHelper插件,SQL如下:
select id,name from table_name where N个条件 limit 100000,10;
它的执行流程:
- 先去二级索引过滤数据,然后找到主键ID
- 通过ID回表查询数据,取出需要的列
- 扫描满足条件的100010,丢弃前面100000条,返回
这里很明显的不足就是只需要拿10条,但是却多回表了100000次。
可采用的策略:主要是使用子查询、关联查询、范围查询和标签记录法这四种方法,当然对于深分页问题,一般都是比较麻烦了,都需要采用标签记录法来改造代码。
标签记录法:就是记录上次查询的最大ID,再请求下一页的时候带上,从上次的下一条数据开始开始,前提是有序的。 主要需要对代码进行改造:
public Page- fetchPageByKey(Long lastKey, int pageSize) {
// lastKey是上一页最后一项的主键
// 查询数据库,获取主键大于lastKey的pageSize条记录
List- items = itemRepository.findByPrimaryKeyGreaterThan(lastKey, pageSize);
// 如果没有更多数据,可以设置下一个lastKey为空或特定值(如-1)
Long nextLastKey = items.isEmpty() ? null : items.get(items.size() - 1).getId();
return new Page<>(items, nextLastKey);
}
来源:juejin.cn/post/7368377525859008522
把java接口写在数据库里(groovy)
业务复杂多变?那把接口写在数据库里吧,修改随改随用!本文使用了Groovy脚本,不了解的可以自行了解,直接上菜。
- 引入依赖
<dependency>
<groupId>org.codehaus.groovygroupId>
<artifactId>groovy-allartifactId>
<version>2.5.16version>
<type>pomtype>
dependency>
- 创建测试接口
public interface InterfaceA {
/**
* 执行规则
*/
void testMethod();
}
- resource目录下创建.groovy实现上面的接口
@Slf4j
class GroovyInterfaceAImpl implements InterfaceA {
@Override
void testMethod() {
log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")
GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)
GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()
.eq("name", "groovy编写的java接口实现类")
.eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })
log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())
}
}
- mysql数据库中建个表groovy_script
5. 将刚才编写的.groovy文件内容存入数据库
@RunWith(SpringRunner.class)
@SpringBootTest
public class GroovyTest {
@Resource
private GroovyScriptService groovyScriptService;
@Test
public void test01() {
GroovyScript groovyScript = new GroovyScript();
groovyScript.setScript("package groovy\n" +
"\n" +
"import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper\n" +
"import com.demo.groovy.entity.GroovyScript\n" +
"import com.demo.groovy.service.GroovyScriptService\n" +
"import com.demo.groovy.service.InterfaceA\n" +
"import com.demo.groovy.util.SpringUtils\n" +
"import groovy.util.logging.Slf4j\n" +
"\n" +
"\n" +
"@Slf4j\n" +
"class GroovyInterfaceAImpl implements InterfaceA {\n" +
"\n" +
" @Override\n" +
" void testMethod() {\n" +
" log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")\n" +
" GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)\n" +
" GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()\n" +
" .eq("name", "groovy编写的java接口实现类")\n" +
" .eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })\n" +
" log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())\n" +
" }\n" +
"}");
groovyScript.setVersion(1);
groovyScript.setName("groovy编写的java接口实现类");
groovyScriptService.save(groovyScript);
}
}
- 从数据读取脚本,GroovyClassLoader加载脚本为Class(注意将Class对象进行缓存)
@Service("groovyScriptService")
@Slf4j
public class GroovyScriptServiceImpl extends ServiceImpl<GroovyScriptServiceMapper, GroovyScript> implements GroovyScriptService {
private static final Map<String, Md5Clazz> SCRIPT_MAP = new ConcurrentHashMap<>();
@Override
public Object getInstanceFromDb(String name, Integer version) {
//查询脚本
GroovyScript groovyScript = Optional.ofNullable(baseMapper.selectOne(new QueryWrapper<GroovyScript>()
.eq("name", name)
.eq("version", version))).orElseThrow(() -> new RuntimeException("没有查询到脚本"));
//将groovy脚本转换为java类对象
Class clazz = getClazz(name + version.toString(), groovyScript.getScript());
Object instance;
try {
instance = clazz.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
return instance;
}
private Class getClazz(String scriptKey, String scriptText) {
String md5Hex = DigestUtil.md5Hex(scriptText);
Md5Clazz md5Script = SCRIPT_MAP.getOrDefault(scriptKey, null);
if (md5Script != null && md5Hex.equals(md5Script.getMd5())) {
log.info("从缓存获取的Clazz");
return md5Script.getClazz();
} else {
CompilerConfiguration config = new CompilerConfiguration();
config.setSourceEncoding("UTF-8");
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader(), config);
try {
Class clazz = groovyClassLoader.parseClass(scriptText);
SCRIPT_MAP.put(scriptKey, new Md5Clazz(md5Hex, clazz));
groovyClassLoader.clearCache();
log.info("groovyClassLoader parseClass");
return clazz;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try {
groovyClassLoader.close();
} catch (IOException e) {
log.error("close GroovyClassLoader error", e);
}
}
}
}
@Data
private static class Md5Clazz {
private String md5;
private Class clazz;
public Md5Clazz(String md5, Class clazz) {
this.md5 = md5;
this.clazz = clazz;
}
}
}
- 测试
@RestController
@RequestMapping("/test")
@Slf4j
public class GroovyTestController {
@Resource
private GroovyScriptService groovyScriptService;
@GetMapping("")
public String testGroovy() {
InterfaceA interfaceA = (InterfaceA) groovyScriptService.getInstanceFromDb("groovy编写的java接口实现类", 1);
interfaceA.testMethod();
return "ok";
}
}
- 接口方法被执行。想要修改接口的话在idea里面把groovy文件编辑好更新到数据库就行了,即时生效。
本文简单给大家提供一种思路,希望能对大家有所帮助,如有不当之处还请大家指正。本人之前在项目中用的比较多的是Groovyshell,执行的是一些代码片段,而GroovyClassLoader则可以加载整个脚本为Class,Groovy对于java开发者来说还是比较友好的,上手容易。
来源:juejin.cn/post/7397013935106048051
Android Activity 之间共享的 ViewModel
Android Activity 之间共享的 ViewModel
- 提供一个 Application 作用域的 ViewModel 去实现
- 要尽量避免被滥用
- 按需考虑加数据销毁、资源释放的逻辑
AppSharedViewModel
class AppSharedViewModel: ViewModel() {
var testLiveData = MutableLiveData(0)
}
class AppApplication : Application(), ViewModelStoreOwner {
companion object {
private lateinit var sInstance: AppApplication
fun getInstance() = sInstance
}
override fun onCreate() {
super.onCreate()
sInstance = this
}
private val appSharedViewModelStore by lazy {
ViewModelStore()
}
override fun getViewModelStore(): ViewModelStore {
return appSharedViewModelStore
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//使用
val appSharedViewModel = ViewModelProvider(AppApplication.getInstance())[AppSharedViewModel::class.java]
}
让 AppSharedViewModel 继承自 AndroidViewModel
class AppSharedViewModel(application: Application) : AndroidViewModel(application) {
var testLiveData = MutableLiveData(0)
}
- 方案1
改写 ViewModel 创建获取的地方传入 AndroidViewModelFactory 实例
val appSharedViewModel = ViewModelProvider(
AppApplication.getInstance(),
ViewModelProvider.AndroidViewModelFactory.getInstance(AppApplication.getInstance())
)[AppSharedViewModel::class.java]
- 方案2
改写 Application 实现 HasDefaultViewModelProviderFactory 接口,因为 ViewModelProvider 构造方法里有调用 ViewModelProvider.AndroidViewModelFactory.defaultFactory 方法传入 ViewModelStoreOwner 去判断处理 HasDefaultViewModelProviderFactory 接口的逻辑
class AppApplication : Application(), ViewModelStoreOwner, HasDefaultViewModelProviderFactory {
companion object {
private lateinit var sInstance: AppApplication
fun getInstance() = sInstance
}
override fun onCreate() {
super.onCreate()
sInstance = this
}
private val appSharedViewModelStore by lazy {
ViewModelStore()
}
override fun getViewModelStore(): ViewModelStore {
return appSharedViewModelStore
}
override fun getDefaultViewModelProviderFactory(): ViewModelProvider.Factory {
return ViewModelProvider.AndroidViewModelFactory.getInstance(this)
}
}
来源:juejin.cn/post/7380579037113237554