开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

神奇的 SQL 之温柔的陷阱 → 为什么是 IS NULL

发表于 2024-03-25

开心一刻

开学了,表弟和同学因为打架,老师让他回去叫家长

表弟硬气的说:不用,我打得过他

老师板着脸对他说:和你打架的那位同学已经回去叫家长了

表弟犹豫了一会,依然硬气的说:可以,两个我也打得过

老师:…

1711244418401.jpg

NULL

NULL 用于表示缺失的值或遗漏的未知数据,不是某种具体类型的值

数据表中的 NULL 值表示该值所处的字段为空,值为 NULL 的字段没有值

尤其要明白的是:NULL 值与 0 或者 空字符串 是不同的!

经过这么一说明,本来不懵的你们是不是有点懵了?

谁来都得懵.jpg

懵就对了,不把你们搞懵,我没法进行下文呀!

两种 NULL

两种 NULL?

SQL 里不是只存在一种 NULL 吗?

你们的脑中闪过以上问题,是非常正常的,但请不要怀疑 两种NULL 的真实性,也不要恐慌你们认知中的正确性,慢慢往下看,这些疑虑都会消除

在讨论 NULL 时,我们一般都会将它分成两种类型来思考:未知(unknown)和 不适用(not applicable,inapplicable),我们来看一个例子,大家就明白这两种类型了

以 不知道戴墨镜的人眼睛是什么颜色 这种情况为例,这个人的眼睛肯定是有颜色的,但是如果他不摘掉眼镜,别人就不知道他的眼睛是什么颜色,这就叫作 未知;而 不知道冰箱的眼睛是什么颜色 则属于 不适用;因为冰箱根本就没有眼睛,所以 眼睛的颜色 这一属性并不适用于冰箱

冰箱的眼睛的颜色 这种说法和 圆的体积、 男性的分娩次数 一样,都是没有意义的、不适用的

平时,我们习惯了说 不知道,但是 不知道 也分很多种;不适用 这种情况下的 NULL ,在语义上更接近于 无意义,而不是 不确定

这里总结一下:未知 指的是 虽然现在不知道,但加上某些条件后就可以知道;而 不适用 指的是 无论怎么努力都无法知道

关系模型的发明者 E.F. Codd 最先给出了这种分类

NULL 分类.png

通过这么一解释,大家对 两种 NULL 是否认可了?

为什么 IS NULL 而非 = NULL

假设我们有表 t_sample_null

1
2
3
4
5
6
7
8
9
10
sql复制代码DROP TABLE IF EXISTS t_sample_null;
CREATE TABLE t_sample_null (
id INT(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
name VARCHAR(50) NOT NULL COMMENT '名称',
remark VARCHAR(500) COMMENT '备注',
primary key(id)
) COMMENT 'NULL样例';

INSERT INTO t_sample_null(name, remark)
VALUES('zhangsan', '张三'),('李四', NULL);

要查询备注为 NULL 的记录,SQL 该如何写?

很多新手小伙伴觉得这还不简单,秒秒钟写出如下 SQL

1
sql复制代码SELECT * FROM t_sample_null WHERE remark = NULL;

一执行会发现:SQL 不报错,但查不出结果

为什么查不出结果,不仅很多新手小伙伴有这样的问题,很多老手其实也没有弄清楚其中的缘由,他们只知道正确的写法是 IS NULL 而不能用 = NULL

1
sql复制代码SELECT * FROM t_sample_null WHERE remark IS NULL;

为什么 IS NULL 而非 = NULL,问题我已经抛出来了,至于原因,我们继续往下看

三值逻辑

看到这个标题,你们第一反应是什么?

觉得我写错了,对不对?

自信点.jpg

你们不这么 自信,怎么体现这篇文章的价值?

有点扯远了,往回收一收

三目运算 你们知道,二值逻辑(true 和 false)你们也知道,但从没听过 三值逻辑

没听过不代表她不存在,在主流的编程语言中(C、JAVA、Python、JS 等)中,逻辑值确实只有 2 个:true 和 false,但在 SQL 中却还存在第三个逻辑值:unknown,这有点类似于我们平时所说的:对、错、不知道

逻辑值 unknown 和作为 NULL 的一种的 UNKNOWN (未知)是不同的东西,前者是明确的布尔型的逻辑值,后者既不是值也不是变量;为了便于区分,前者采用小写字母 unknown ,后者用大写字母 UNKNOWN 来表示

为了让大家理解两者的不同,我们来看一个 x=x 这样的简单等式;x 是逻辑值 unknown 时,x=x 被判断为 true ,而 x 是 UNKNOWN 时被判断为 unknown

1
2
3
4
5
sql复制代码-- 这个是明确的逻辑值的比较
unknown = unknown → true

-- 这个相当于NULL = NULL
UNKNOWN = UNKNOWN → unknown

逻辑值表

因为有了 unknow 这个逻辑值,逻辑运算的情况就比二值逻辑复杂

NOT

NOT 逻辑值.png

AND

AND 逻辑值.png

OR

OR 逻辑值.png

图中蓝色部分是三值逻辑中独有的运算,这在二值逻辑中是没有的

其余的 SQL 谓词全部都能由这三个逻辑运算组合而来

从这个意义上讲,这个几个逻辑表可以说是 SQL 的 母体(matrix)

NOT 的话,因为逻辑值表比较简单,所以很好记

但是对于 AND 和 OR,因为组合出来的逻辑值较多,所以全部记住非常困难,为了便于记忆,请注意这三个逻辑值之间有下面这样的优先级顺序

AND 的情况:false > unknown > true

OR 的情况:true > unknown > false

优先级高的逻辑值会决定计算结果,例如 true AND unknown ,因为 unknown 的优先级更高,所以结果是 unknown ;而 true OR unknown 的话,因为 true 优先级更高,所以结果是 true ;记住这个顺序后就能更方便地进行三值逻辑运算了;特别需要记住的是,当 AND 运算中包含 unknown 时,结果肯定不会是 true ,反之,如果 AND 运算结果为 true ,则参与运算的双方必须都为 true

下面的逻辑运算,你们算对了吗?

1
2
3
4
5
6
sql复制代码-- 假设 a = 2, b = 5, c = NULL,下列表达式的逻辑值如下

a < b AND b > c → unknown
a > b OR b < c → unknown
a < b OR b < c → true
NOT (b <> c) → unknown

IS NULL 而非 = NULL

我们再回到问题:为什么 IS NULL 而非 = NULL

对 NULL 使用比较谓词后得到的结果总是 unknown ,而查询结果只会包含 WHERE 子句里的判断结果为 true 的行,不会包含判断结果为 false 和 unknown 的行

不只是等号,对 NULL 使用其他比较谓词,结果也都是一样的

所以无论 remark 是不是 NULL ,比较结果都是 unknown ,那么永远没有结果返回

以下的式子都会被判为 unknown

1
2
3
4
5
6
sql复制代码-- 以下的式子都会被判为 unknown
= NULL
> NULL
< NULL
<> NULL
NULL = NULL

这下大家都清楚其中缘由了吧?

640 (4).png

但是,注意,但是来了,大家打起精神来!

为什么对 NULL 使用比较谓词后得到的结果永远不可能为真呢?
这是因为,NULL 既不是值也不是变量,它只是一个表示 没有值 的标记,而比较谓词只适用于值,因此,对并非值的 NULL 使用比较谓词本来就是没有意义的

列的值为 NULL 、NULL 值 这样的说法本身就是错误的,因为 NULL 不是值,所以不在定义域(domain)中

相反,如果有人认为 NULL 是值,那么我们可以倒过来想一下:它是什么类型的值?

关系数据库中存在的值必然属于某种类型,比如字符型或数值型等,所以,假如 NULL 是值,那么它就必须属于某种类型

NULL 容易被认为是值的原因有两个

第一个原因是高级编程语言里面,NULL 被定义为了一个常量(很多语言将其定义为了整数 0),这导致了我们的混淆。但是,SQL 里的 NULL 和其他编程语言里的 NULL 是完全不同的东西

第二个原因是 IS NULL 这样的谓词是由两个单词构成的,所以我们容易把 IS 当作谓词,而把 NULL 当作值。特别是 SQL 里还有 IS TRUE 、IS FALSE 这样的谓词,我们由此类推,从而这样认为也不是没有道理。但是正如讲解标准 SQL 的书里提醒人们注意的那样,我们应该把 IS NULL 看作是一个谓词。因此,写成 IS_NULL 这样也许更合适

说了这么多,是不是又触及到你们的知识盲区了?

学无止境.jpg

温柔的陷阱

理论已经讲完了,接下来我们结合实例来看看 NULL 的陷阱

比较谓词和 NULL

排中律不成立

排中律 指同一个思维过程中,两个相互矛盾的思想不能同假,必有一真,即 要么A要么非A
很多编程语言中,排中律 是成立的,但是 SQL 中还是如此吗?

假设我们有学生表:t_student

1
2
3
4
5
6
7
8
9
10
11
12
13
sql复制代码DROP TABLE IF EXISTS t_student;
CREATE TABLE t_student (
id INT(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
name VARCHAR(50) NOT NULL COMMENT '名称',
age INT(3) COMMENT '年龄',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
primary key(id)
) COMMENT '学生信息';

INSERT INTO t_student(name, age)
VALUE('zhangsan', 25),('wangwu', 60),('bruce', 32),('yzb', NULL),('boss', 18);

SELECT * FROM t_student;

表中数据 yzb 的 age 是 NULL,也就是说 yzb 的年龄未知

在现实世界里,yzb 是 20 岁,或者不是 20 岁,二者必居其一,这毫无疑问是一个真命题

那么在 SQL 的世界里了,排中律 还适用吗? 我们来看一个 SQL

1
2
sql复制代码SELECT * FROM t_student
WHERE age = 20 OR age <> 20;

乍一看,这不就是查询表中全部记录吗?

我们来看下实际结果

排中律.gif

yzb 这条记录竟然没查出来!

20230115143818.png

这是为什么呢?

我们来分析下,yzb 的 age 是 NULL,那么这条记录的判断步骤如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sql复制代码-- 1. 约翰年龄是 NULL (未知的 NULL !)
SELECT *
FROM t_student
WHERE age = NULL
OR age <> NULL;

-- 2. 对 NULL 使用比较谓词后,结果为unknown
SELECT *
FROM t_student
WHERE unknown
OR unknown;

-- 3.unknown OR unknown 的结果是unknown (参考三值逻辑的逻辑值表)
SELECT *
FROM t_student
WHERE unknown;

SQL 语句的查询结果里只有判断结果为 true 的行,所以 yzb 这条记录没查出来

要想让 yzb 这条记录出现在结果里,需要添加下面这样的第 3 个条件

1
2
3
4
5
sql复制代码-- 添加 3 个条件:年龄是20 岁,或者不是20 岁,或者年龄未知
SELECT * FROM t_student
WHERE age = 20
OR age <> 20
OR age IS NULL;

CASE 表达式和 NULL

简单 CASE 表达式如下

1
2
3
4
sql复制代码CASE col_1
WHEN = 1 THEN 'o'
WHEN NULL THEN 'x'
END

这个 CASE 表达式一定不会返回 ×,没问题吧?

这是因为,第二个 WHEN 子句是 col_1 = NULL 的缩写形式;正如我们所知,这个式子的逻辑值永远是 unknown ,而且 CASE 表达式的判断方法与 WHERE 子句一样,只认可逻辑值为 true 的条件

正确的写法是像下面这样使用搜索 CASE 表达式

1
2
3
sql复制代码CASE WHEN col_1 = 1 THEN 'o'
WHEN col_1 IS NULL THEN 'x'
END

NOT IN 和 NOT EXISTS 不等价

我们在对 SQL 语句进行性能优化时,经常用到的一个技巧是将 IN 改写成 EXISTS ,这是等价改写,并没有什么问题

但是,将 NOT IN 改写成 NOT EXISTS 时,结果未必一样

我们来看个例子,我们有如下两张表:t_student_A 和 t_student_B,分别表示 A 班学生与 B 班学生

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
sql复制代码DROP TABLE IF EXISTS t_student_A;
CREATE TABLE t_student_A (
id INT(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
name VARCHAR(50) NOT NULL COMMENT '名称',
age INT(3) COMMENT '年龄',
city VARCHAR(50) NOT NULL COMMENT '城市',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
primary key(id)
) COMMENT '学生信息';

INSERT INTO t_student_A(name, age, city)
VALUE
('zhangsan', 25,'深圳市'),('wangwu', 60, '广州市'),
('bruce', 32, '北京市'),('yzb', NULL, '深圳市'),
('boss', 43, '深圳市');

DROP TABLE IF EXISTS t_student_B;
CREATE TABLE t_student_B (
id INT(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
name VARCHAR(50) NOT NULL COMMENT '名称',
age INT(3) COMMENT '年龄',
city VARCHAR(50) NOT NULL COMMENT '城市',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
primary key(id)
) COMMENT '学生信息';

INSERT INTO t_student_B(name, age, city)
VALUE
('马化腾', 45, '深圳市'),('马三', 25, '深圳市'),
('马云', 43, '杭州市'),('李彦宏', 41, '深圳市'),
('年轻人', 25, '深圳市');

SELECT * FROM t_student_A;
SELECT * FROM t_student_B;

需求:查询与 A 班住在深圳的学生年龄不同的 B 班学生,也就说查询出 :马化腾 和 李彦宏

这个 SQL 该如何写?

有小伙伴觉得,这还不简单?秒秒钟就写出如下 SQL

1
2
3
4
5
6
sql复制代码-- 查询与 A  班住在深圳的学生年龄不同的 B 班学生 ?
SELECT * FROM t_student_B
WHERE age NOT IN (
SELECT age FROM t_student_A
WHERE city = '深圳市'
);

你执行下就会发现结果并非如你所想

not_in_not_exists.gif

竟然没有查到任何数据!

640 (2).jpg

这是为什么呢?

其实又是 NULL 开始作怪了,我们一步一步来看看究竟发生了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
sql复制代码-- 1. 执行子查询,获取年龄列表
SELECT * FROM t_student
WHERE age NOT IN(43, NULL, 25);

-- 2. 用 NOT 和 IN 等价改写NOT IN
SELECT * FROM t_student
WHERE NOT age IN (43, NULL, 25);

-- 3. 用 OR 等价改写谓词 IN
SELECT * FROM t_student
WHERE NOT ( (age = 43) OR (age = NULL) OR (age = 25) );

-- 4. 使用 德·摩根定律 等价改写
SELECT * FROM t_student
WHERE NOT (age = 43) AND NOT(age = NULL) AND NOT (age = 25);

-- 5. 用 <> 等价改写 NOT 和 =
SELECT * FROM t_student
WHERE (age <> 43) AND (age <> NULL) AND (age <> 25);

-- 6. 对 NULL 使用 <> 后,结果为 unknown
SELECT * FROM t_student
WHERE (age <> 43) AND unknown AND (age <> 25);

-- 7.如果 AND 运算里包含 unknown,则结果不为true(参考三值逻辑的逻辑值表)
SELECT * FROM t_student
WHERE false 或 unknown;

可以看出,在进行了一系列的转换后,没有一条记录在 WHERE 子句里被判断为 true

也就是说,如果 NOT IN 子查询中用到的表里被选择的列中存在 NULL ,则 SQL 语句整体的查询结果永远是空。这是很可怕的现象!

为了得到正确的结果,我们需要使用 EXISTS 谓词

1
2
3
4
5
6
7
sql复制代码-- 正确的SQL 语句:马化腾和李彦宏将被查询到
SELECT * FROM t_student_B B
WHERE NOT EXISTS (
SELECT * FROM t_student_A A
WHERE B.age = A.age
AND A.city = '深圳市'
);

执行结果如下

not_exists.gif

同样地,我们再来一步一步地看看这段 SQL 是如何处理年龄为 NULL 的行的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sql复制代码-- 1. 在子查询里和 NULL 进行比较运算,此时 A.age 是 NULL
SELECT * FROM t_student_B B
WHERE NOT EXISTS (
SELECT * FROM t_student_A A
WHERE B.age = NULL
AND A.city = '深圳市'
);

-- 2. 对 NULL 使用 = 后,结果为 unknown
SELECT * FROM t_student_B B
WHERE NOT EXISTS (
SELECT * FROM t_student_A A
WHERE unknown
AND A.city = '深圳市'
);

-- 3. 如果 AND 运算里包含 unknown,结果不会是true
SELECT * FROM t_student_B B
WHERE NOT EXISTS (
SELECT * FROM t_student_A A
WHERE false 或 unknown
);

-- 4. 子查询没有返回结果,因此相反地,NOT EXISTS 为 true
SELECT * FROM t_student_B B
WHERE true;

也就是说,yzb 被作为 与任何人的年龄都不同的人 来处理了

EXISTS 只会返回 true 或者 false,永远不会返回 unknown,因此就有了 IN 和 EXISTS 可以互相替换使用,而 NOT IN 和 NOT EXISTS 却不可以互相替换的混乱现象

还有一些其他的陷阱,比如:限定谓词 和 NULL、限定谓词 和 极值函数 不是等价的、聚合函数 和 NULL 等等

分析方法我已经教给你们了,你们要会成长起来!

父辈30 我还没30.jpg

总结

1、NULL 用于表示缺失的值或遗漏的未知数据,不是某种具体类型的值,不能对其使用谓词

2、对 NULL 使用谓词后的结果是 unknown,unknown 参与到逻辑运算时,SQL 的运行会和预想的不一样

3、IS NULL 整个是一个谓词,而不是:IS 是谓词,NULL 是值;类似的还有 IS TRUE、IS FALSE

4、要想解决 NULL 带来的各种问题,最佳方法应该是往表里添加 NOT NULL 约束来尽力排除 NULL

我的项目中有个硬性规定:所有字段必须是 `NOT NULL`,建表的时候就加上此约束

参考

《SQL进阶教程》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【译文】React 中 Form 的最佳实践

发表于 2024-03-25

作者:郜克帅

原文:dev.to/ajones_code…

React 生态拥有丰富的库、文章、视频和几乎你能想到的所有 Web 领域的资料。然而,随着时间的推移,这些资料许多都已经过时,无法满足现代最佳实践的要求了。

最近,我在开发一个 AI 项目,里面有许多复杂的动态表单。在研究了许多优秀的 React 表单指南之后,我意识到,大多数构建表单的资源都已经过时了,而且往往已经过时很多年。

本文将介绍 React 中构建表单的现代最佳实践、如何去构建动态表单、 RSC(React Server Components)的表单等等。最后,在理解了这些之后,我将解释我在其他指南中发现的不足,并根据我使用 React 的经验提出建议。

受控与非受控

理解 React 中表单的关键点在于 “受控” 与 “非受控” 的概念,这是 React 中构建表单的两种不同的方法。

受控表单使用 state 存储每个 input 的值,然后在每次渲染时通过 value 属性设置对应 input的值。如果其他函数更新了这些 state,同样的,对应 input 的值也会立刻改变。

如果你的代码没有渲染 Form,但相关的 state 并不会消失,仍然存在于我们的运行时上下文中。

受控表单往往给予了我们更大的选择,例如比较复杂的、非 HTML 标准的表单校验,如检查密码强度和对用户手机号进行格式化。

它们看起来往往是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
javascript复制代码import React, { useState } from 'react'

function ControlledForm() {
const [value, setValue] = useState('');

const handleChange = (event) => {
setValue(event.target.value);
};

const handleSubmit = () => {
sendInputValueToApi(value).then(() => /** 业务逻辑... */);
};

return (
<>
<input type="text" value={value} onChange={handleChange} />
<button onClick={handleSubmit}>send</button>
</>
)
}

注意,用 <form> 将 input 包裹起来并且给 input 一个命名从语义上来讲更加准确,但是这不是必需的。

因为数据已经保存在 state 中,所以我们并不需要真正的 onSubmit事件,而且在按钮点击时,我们也并不需要直接访问 input 的值。

这种方式有一些不足之处:

  1. 你可能不想要每次用户输入时都去重新渲染组件。
  2. 你需要写许多代码去管理复杂的表单,因为随着表单规模的增长,会导致出现大量的 state 和 setSate,从而使代码变的非常臃肿。
  3. 构建动态表单将变的非常困难,因为你无法在条件判断中使用像 useState 的 hooks。为了修复这个问题,你可能需要:
  1. 整个表单的值将存储在一个巨大的对象中,然而这会导致所有的子组件将在任一其他组件变化时全部重新渲染,因为我们更新的方式是 setState({ ...preState, field: newValue })。 要解决上述的问题,唯一的办法就是缓存,但这又会增加大量的代码。
  1. 在大型表单例如表格和 Excel 中,这会导致性能问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
ini复制代码import React, { useState } from "react";

function CumbersomeForm() {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
address: "",
// ... 可能会有更多的值
});

const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prevState) => ({ ...prevState, [name]: value }));
};

return (
<>
<label>First Name:</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
<label>Last Name:</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
/>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<label>Address:</label>
<input
type="text"
name="address"
value={formData.address}
onChange={handleChange}
/>
{/* ... 可能会有更多的字段 */}
</>
);
}

与受控表单不同的是,非受控表单不在 state 中存储表单的值。相反,非受控表单使用原生 HTML 内置的 <form> 的功能和 JavaScript 去管理数据。

举例来说,浏览器会帮我们管理状态,我们无需在每次 input 改变时使用 setState 更新 state 并把 state 设置到 input 的 value 属性上,我们的组件不再需要或使用这些 state

当组件渲染时,React 会将 onSubmit 监听器添加到表单上。当提交按钮被点击时,我们的 handleSubmit 函数会被执行。与使用 state相比,它更接近于不使用任何 JavaScript 的普通 HTML 表单的工作方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码function UncontrolledForm() {

const handleSubmit = (event) => {
event.preventDefault();

const formData = new FormData(event.target);
const inputValue = formData.get('inputName');

sendInputValueToApi(inputValue).then(() => /* 业务逻辑... */)
};

return (
<form onSubmit={handleSubmit}>
<input type="text" name="inputName" />
<button type="submit">Send</button>
</form>
);
}

使用非受控表单的一个好处就是会减少大量的冗余代码:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码// 受控
const [value, setValue] = useState('')

const handleChange = (event) => {
setValue(event.target.value);
}
...
<input type="text" value={value} onChange={handleChange} />

// 非受控
<input type="text" name="inputName" />

即便只有 1 个 input,区别也是非常显著的,当有许多 input 时,效果会更加明显:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
javascript复制代码function UncontrolledForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const { name, email, address } = Object.fromEntries(formData);
};

return (
<form onSubmit={handleSubmit}>
<label>First Name:</label>
<input type="text" name="firstName" />

<label>Last Name:</label>
<input type="text" name="lastName" />

<label>Email:</label>
<input type="email" name="email" />

<label>Address:</label>
<input type="text" name="address" />
{/* ... 可能会有更多的字段 */}
<button type="submit">Submit</button>
</form>
);
}

非受控表单与受控表单相比,没有许多冗余的代码,并且我们不需要手动管理许许多多的 state 或一个巨大的对象。事实上,这里根本没有 state。这个表单可以有成百上千个子组件但它们不会导致彼此重新渲染。使用这种方式,会让表单性能变的更好、减少大量的冗余代码并且使我们代码的可读性更强。

非受控表单的不足之处是你无法直接访问每个 input 的值。这会使自定义校验变的棘手。例如你需要在用户输入手机号的时候格式化手机号。

注意事项

不要使用 useRef

许多文章推荐在非受控表单的每个 input 上使用一个 ref 而不是使用 new FormData(),我认为原因是 FormData API 很少人知道。然而,它在大约十年前已经成为了一个标准并且已经被所有主流浏览器支持。

我强烈建议你不要为表单使用 useRef,因为它会像 useState 一样引入许多相同的问题和冗余的代码。

然而,确实有一些场景,ref 可以帮助我们。

  1. 聚焦字段时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码function MyForm() {
const inputRef = useRef(null);

const focusInput = () => {
inputRef.current.focus();
};

return (
<form>
<input ref={inputRef} type="text" />
<button type="button" onClick={focusInput}>
Focus Input
</button>
</form>
);
}
  1. 调用子组件的方法时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
javascript复制代码const ChildComponent = React.forwardRef((props, ref) => (
<input ref={ref} type="text" />
));

function MyForm() {
const inputRef = useRef(null);

const focusInput = () => {
inputRef.current.focus();
};

return (
<form>
<ChildComponent ref={inputRef} />
<button type="button" onClick={focusInput}>
Focus Input
</button>
</form>
);
}
  1. 其他的例如保存 useEffect 的前一个值或测量一个元素大小时

混合受控与非受控

在许多场景中,你可能需要控制一个或更多的 input,当用户在输入手机号码时对其进行格式化是一个非常棒的例子。在这些场景中,即便你正在使用非受控表单,你也可以使用一个受控的 input。在这种情况下,也不要使用 state 去访问 input 的值,继续使用 new FormData(...),仅仅使用 state 去管理相关输入的展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
javascript复制代码function MixedForm() {
const [phoneNumber, setPhoneNumber] = useState("");

const handlePhoneNumberChange = (event) => {
// 格式化手机号
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};

const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
};

return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" />

<label>Email:</label>
<input type="email" name="email" />

<label>Phone Number:</label>
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>

<label>Address:</label>
<input type="text" name="address" />

<button type="submit">Submit</button>
</form>
);
}

function formatPhoneNumber(number) {
return number.replace(/\D/g, "").slice(0, 10);
}

注意:尽量减少 state,在这个例子中,你不会既想要一个保存原始电话号码的 useState,又想要一个用于格式化电话号码的 useState,并且因为同步它们还会带来多余的重新渲染的效果。

谈到重新渲染优化,我们可以将受控 input 抽离出来以此来减少重新渲染对表单其余部分的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
javascript复制代码const PhoneInput = () => {
const [phoneNumber, setPhoneNumber] = useState("");

const handlePhoneNumberChange = (event) => {
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};

return (
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
);
};

function MixedForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
};

return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" />

<label>Email:</label>
<input type="email" name="email" />

<label>Phone Number:</label>
<PhoneInput />

<label>Address:</label>
<input type="text" name="address" />

<button type="submit">Submit</button>
</form>
);
}

function formatPhoneNumber(number) {
return number.replace(/\D/g, "").slice(0, 10);
}

如果你用过受控 input,那么看完上面代码之后,你可能会想:“没有传递任何 setState 或 ref, 父组件是如何知道子组件的值”。为了理解这个问题,请记住,当 React 代码被渲染成 HTML 时,浏览器只会看到 Form 和它里面的 inputs,包括 <PhoneInput /> 渲染的 input。

我们的组件组合方式对我们渲染的 HTML 没有功能上的影响。因此,那个 input 的值会像其他字段一样被包含在 FormData 中。这就是组件组合和封装的力量。我们可以将重新渲染控制在最小影响范围内,与此同时,DOM 依然像原生 HTML 一样呈现。

等等… 我如何在非受控 input 中做校验?

考虑到这个问题的并非只有你一个!当在提交前需要校验时,React 开发者往往会倾向于去使用受控组件。

许多开发者并没有意识到,你并不需要 React 或自定义的 JavaScript 做这些校验。事实上,有一些原生的属性已经支持了这些事情。请参阅 MDN 查看更多的细节:developer.mozilla.org/en-US/docs/…

在不使用任何 JavaScript 的前提下,你可以设置 input 必填、设置长度限制和用正则表达式设置格式要求。

错误处理

在相关的讨论中,在我们需要在客户端展示错误信息的时候,开发者通常会选择受控表单来解决这个问题。然而,我会优先选择使用非受控组件并在我的 onSubmit 函数里面做校验和错误管理,而不是使用受控组件并在每次 input 改变时更新对应的 state。这种方式可以尽量减少 state 和 setState 的数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
ini复制代码function UncontrolledForm() {
const [errors, setErrors] = useState({});

const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);

let validationErrors = {};

// 自定义校验:确保邮箱的域名是:"example.com"
const email = formData.get("email");
if (email && !email.endsWith("@example.com")) {
validationErrors.email = "Email must be from the domain example.com.";
}

if (formData.get("phoneNumber").length !== 10) {
validationErrors.phoneNumber = "Phone number must be 10 digits.";
}

if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
} else {
console.log(Array.from(formData.entries()));
// 清空之前的值
setErrors({});
}
};

return (
<form onSubmit={handleSubmit}>
<label>Name:</label>
<input type="text" name="name" required />
{errors.name && <div className="error">{errors.name}</div>}

<label>Email (must be @example.com):</label>
<input type="email" name="email" required />
{errors.email && <div className="error">{errors.email}</div>}

<label>Phone Number (10 digits):</label>
<input type="tel" name="phoneNumber" required pattern="\d{10}" />
{errors.phoneNumber && <div className="error">{errors.phoneNumber}</div>}

<button type="submit">Submit</button>
</form>
);
}

export default UncontrolledForm;

服务端组件中的 Form

React Server Components(RSC) 使用服务端框架去渲染部分组件,通过这种办法可以减少浏览器访问你网站时下载的 JavaScript 的数量。这可以显著地提升你网站的性能。

RSC 对我们编写表单的方式有很大的影响。因为,对于首次渲染来说,如果我们没有使用 state,它们可以在服务端被渲染并不附带任何 JavaScript 文件给浏览器。这意味着,非受控表单即使在没 JavaScript的情况下也可以交互,意味着它们可以更早的工作而不用等待 JavaScript 去下载然后运行。这可以让你的网站体验更加丝滑。

使用 Next.js,你可以在你的表单中使用 Server Actions,因此你不需要去为了你的表单交互写一个 API。你需要准备的只是一个事件处理函数。你可以在 Next.js 的文档中找到关于这个主题的更多内容或者观看是 Lee 的视频。

如果你要在 RSC 中混合一些受控表单,请确保把它们抽离为单独的客户端组件,就像上面的 <PhoneInput /> 一样。这可以尽可能的减少需要打包的 JavaScript 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
javascript复制代码// page.jsx
import { PhoneInput } from "./PhoneInput";

export default function Page() {
async function create(formData: FormData) {
"use server";

// ... use the FormData
}

return (
<form action={create}>
<label>Name:</label>
<input type="text" name="name" />

<label>Email:</label>
<input type="email" name="email" />

<label>Phone Number:</label>
<PhoneInput />

<label>Address:</label>
<input type="text" name="address" />

<button type="submit">Submit</button>
</form>
);
}

// PhoneInput.jsx
"use client";

function formatPhoneNumber(number) {
return number.replace(/\D/g, "").slice(0, 10);
}

import { useState } from "react";

export const PhoneInput = () => {
const handlePhoneNumberChange = (event) => {
const formattedNumber = formatPhoneNumber(event.target.value);
setPhoneNumber(formattedNumber);
};
const [phoneNumber, setPhoneNumber] = useState("");

return (
<input
type="tel"
name="phoneNumber"
value={phoneNumber}
onChange={handlePhoneNumberChange}
/>
);
};

Form 库

在 React 生态中有许多为受控表单设计的优秀的库。最近我一直在使用 React Hook Form 来处理这些应用,不过我更倾向于使用非受控表单,因为不需要额外的库来管理表单状态。(一些流行的库:React Hook Form、Formik和Informed)

总结、对比和推荐

因为 Google 搜索 react forms 时排名靠前的文章令人感到困惑、过时或具有误导性,因此我写了本文。

  • 其中一篇文章说:“React 中更通用的方式是受控表单”,我不认为受控或非受控谁更通用。实际上,正如上文所述,这两种类型都有其用武之地。事实上,许多旧文章都推荐使用受控表单,同时理由同样含糊不清或具有误导性。
  • 没有一篇排名靠前的文章使用 FormData。对于非受控表单,至少两篇文章推荐使用 useRef,这同样会让你的代码变的不灵活且臃肿。
  • 一些排名靠前的文章仍然在使用类组件,没有提到函数式组件😂。

一些总结性的看法:

  1. 以我的经验来看,许多表单都是受控和不受控混合的。我们之所以有这两种选择,是因为我们有灵活性,不应该教条主义。我们可以使用同时使用它们,就像上面的 RSC 例子一样。
  2. 时至今日,我更偏爱于使用非受控表单,我认为这会简化代码结构并优化性能。
  3. 认真的说,在 onSubmit 函数中使用 new FormData(...)而不要使用 useRef。
  4. 封装和组合受控表单去尽量减少 state 更新对其他组件的影响,并依靠组合后的 DOM 来处理提交事件。

我希望这篇文章可以帮助到你!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

15 分钟带你感受 CSS has() 选择器的强大

发表于 2024-03-24

最近看到了许多关于 :has() 选择器的知识点,在此总结下来。

MDN 对 :has() 选择器 的解释是这样的:

CSS 函数式伪类 :has() 表示一个元素,如果作为参数传递的任何相对选择器在锚定到该元素时,至少匹配一个元素。这个伪类通过把可容错相对选择器列表作为参数,提供了一种针对引用元素选择父元素或者先前的兄弟元素的方法。

下面一起来感受下 :has() 选择器的强大之处吧。

:has() 选择器选择父元素和前面的兄弟元素

邻接兄弟选择器(+)用来选中恰好处于另一个在继承关系上同级的元素旁边的物件。例如,选中所有紧随<p>元素之后的<img>元素:

1
css复制代码p + img

通用兄弟关系选择器(~)用来选中一个元素后面的所有兄弟元素。例如,选中<p>元素之后的所有的<img>元素:

1
css复制代码p ~ img

css 并没有提供直接选择父元素或者前面的兄弟元素的选择器,但 :has() 可以做到这点。

1、比如选择所有包含 <p>元素的父元素:

1
css复制代码:has(p)

2、选择直接后代元素包含 <p>元素的父元素:

1
css复制代码:has(> p)

3、选择直接后代元素包含 <p>元素的父级标签名是 div 父元素:

1
css复制代码div:has(> p)

4、选择 <p>元素的相邻的前一个标签名是 div 的兄弟元素:

1
css复制代码div:has(+ p)

5、选择 <p>元素的前面所有标签名是 div 的兄弟元素:

1
css复制代码div:has(~ p)

:has() 选择器中的 且 和 或

在 :has() 选择器中表示 且 和 或 很简单,例如:

p:has(.a):has(.b) 表示选择同时包含子元素 a 和 子元素 b 的 元素 p

p:has(.a, .b) 表示选择包含子元素 a 或者包含子元素 b 的 元素 p

:has() 选择器选择一个范围内的元素

现在有如下元素

1
2
3
4
5
6
7
css复制代码<div>
<h2>标题开始(选择第一行字体为绿色,最后一行字体为红色)</h2>
<p>h2中间第一行</p>
<h4>h2中间第二行</h4>
<h5>h2中间最后一行</h5>
<h2>标题结束</h2>
</div>

要求选择第一行字体为绿色,最后一行字体为红色。需要注意的是,中间元素可以是任意的。

cc.png

使用 :has() 实现上面效果,可以这么做

1
2
3
4
5
6
7
8
css复制代码/* 选择 h2 中间第一行 */
h2 + :has(~ h2){
color:green;
}
/* 选择 h2 中间最后一行 */
h2 ~ :has(+ h2){
color:red;
}

h2 + :has(~ h2) 表示选择紧跟着 h2 的并且后面还有 h2 元素的兄弟元素。也就选择到了 h2 范围内的第一个元素。

h2 ~ :has(+ h2) 表示选择 h2 后面的兄弟元素,并且该兄弟元素的下一个兄弟元素是 h2,也就选择到了 h2 范围内最后一个元素

那如果要选择中间所有元素呢,可以这样做

dd.png

1
2
3
4
css复制代码/* 选择 hr 中间所有行 */
hr ~ :has(~ hr){
color:blue;
}

:has() 选择器的应用

1、CSS :has() 选择器之星级评分

关于星级评分,之前写过一篇文章分享过 三种方式使用纯 CSS 实现星级评分。

这里介绍下使用 :has() 选择器 + :not() 选择器 实现星级评分的方式。

星级评分效果包括鼠标滑入和点击,滑入或点击到第几颗星的位置,该位置之前的星高亮,之后的星不高亮或者有高亮的则取消高亮;

star.webp

html 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
html复制代码<div>
<input type="radio" name="radio" id="radio1">
<label for="radio1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio2">
<label for="radio2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio3">
<label for="radio3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio4">
<label for="radio4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio5">
<label for="radio5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
</div>

为了使星星有点击效果,利用 radio + label 的方式实现点击效果;label 代表星星。

当点击星星时,高亮当前星星

1
2
3
css复制代码input:checked + label{
color:gold;
}

当鼠标移入星星时,高亮当前星星,并且该位置之后的星星取消高亮;

1
2
3
4
5
6
css复制代码label:hover{
color:gold;
& ~ label{
color:#ccc!important;
}
}

让当前位置之前的所有星星也高亮,可以利用 :not ,排除掉当前位置和当前位置之后的星星。

1
2
3
css复制代码label:not(:hover,:hover ~ *){
color:gold;
}

并且只有鼠标滑入时添加这些效果。

1
2
3
css复制代码div:has(label:hover) label:not(:hover,:hover ~ *){
color:gold;
}

同样,当点击星星时,点亮当前选择的之前所有的星星也如此

1
2
3
css复制代码div:has(input:checked) label:not(input:checked ~ label){
color:gold;
}

完整示例

2、CSS :not 和 :has() 模拟 :only-of-type

有下面的 html 结构

1
2
3
4
5
6
html复制代码<div>
<p>第一页</p>
<p class="this">第二页</p>
<p>第三页</p>
<p>第四页</p>
</div>

要选择类名为 this 的元素,并设置颜色为红色,使用 .this{color:red;} 可以轻松做到。

aa.png

如果现在有两个 div 元素块

1
2
3
4
5
6
7
8
9
10
11
12
13
html复制代码<div>
<p>第一页</p>
<p class="this">第二页</p>
<p>第三页</p>
<p>第四页</p>
</div>

<div>
<p>第一页</p>
<p class="this">第二页</p>
<p class="this">第三页</p>
<p>第四页</p>
</div>

现要求选择 div 的子元素中只有含有一个类名为 this 的元素(也就是第一个 div 元素块),并且设置其颜色为红色,该怎么做呢?

:only-of-type 代表了任意一个元素,这个元素没有其他相同类型的兄弟元素。

但 :only-of-type 判断是否有相同类型的依据是标签名,而不是类名。所以并不能达到想要的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
css复制代码//这种写法是无效的,无法判断元素有没有其他相同的类名。
.this:only-of-type {
color:red;
}

//这种写法是有效的,但判断的是没有相同的 p 的元素,显然无法满足上面的要求,但能匹配下面 ul 中的 p
p:only-of-type {
color:red;
}

<ul>
<li>第一页</li>
<li class="this">第二页</li>
<li class="this">第三页</li>
<p>第四页</p>
</ul>

而 :has 能做到,要选择前后没有相同类名的元素 ,也就是排除前后的 .this 。

排除前面的 .this

1
2
js复制代码// 表示选择前面没有 .this 的 .this
.this:not(.this ~)

排除后面的 .this,

1
2
js复制代码// 表示排除后面有 .this 的 .this
.this:not(:has(~ .this))

两个做并集,也就选择到了唯一的 .this

1
2
3
js复制代码.this:not(:has(~ .this)):not(.this ~ *){
color:red;
}

bb.png

完整示例

3、CSS :has() 选择器之模仿 mac 电脑 dock 栏

利用 :has() 可以选择到前面的兄弟元素的特点,还能做出下面的动画效果

aa.gif

当鼠标滑入到一个元素时,该元素放大,该元素的前一个元素和后一个元素缩小,除了这三个元素之外的其他元素缩的更小并且有一定透明度;

html 结构如下

1
2
3
4
5
6
7
8
9
10
11
12
html复制代码<div class="box">
<div class="son">乔丹</div>
<div class="son">科比</div>
<div class="son">詹姆斯</div>
<div class="son">奥尼尔</div>
<div class="son">邓肯</div>
<div class="son">卡特</div>
<div class="son">麦迪</div>
<div class="son">艾弗森</div>
<div class="son">库里</div>
<div class="son">杜兰特</div>
</div>

关键 css 代码

1
2
3
4
5
6
7
8
9
10
11
12
css复制代码.son{
...
...
...
&:hover{
background-color:#67c23a;
transform:scale(1.4);
& + .son{
transform:scale(1.1); // 后一个相邻的兄弟元素
}
}
}

让前一个元素也缩放为原来的 1.1

1
2
3
4
css复制代码// 选择存在 后一个相邻的被hover的兄弟元素 的元素
.son:has( + .son:hover){
transform:scale(1.2);
}

然后对这三个元素之外的其他元素缩放为原来的 0.8

1
2
3
4
css复制代码.box:has(.son:hover) .son:not(:hover, :has(+ :hover), .son:hover + *) {
transform:scale(0.8);
opacity:0.7;
}

.box:has(.son:hover) 表示选择子元素 son 被 hover 时的 .box

.son:not(:hover, :has(+ :hover), .son:hover + *) 表示排除 son 元素里面被 hover 的元素,被 hover 的元素的前一个邻接的兄弟元素,被 hover 的元素的后一个邻接的兄弟元素;

完整示例

4、CSS :has() 选择器之单选题

bb.gif

这是个有趣的应用,当选择的是错误的选项时,选择题的标题和当前选择项标红。并且会给正确的选项添加动画效果提示用户这才是正确选项。

这里用 data-correct="false" 表示错误的选项,data-correct="true" 表示正确的选项。

1
2
3
4
5
6
7
8
html复制代码<input type="radio" name="option" data-correct="false" id="option1" />
<label for="option1">Responsive design</label>

<input type="radio" name="option" data-correct="true" id="option2" />
<label for="option2">Responsive design</label>

<input type="radio" name="option" data-correct="false" id="option3" />
<label for="option3">Responsive design</label>

选择错误选项时,标红当前选项。选择正确选项时标绿当前选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
css复制代码.question{
--correct: #5ed235; // 正确选项的颜色
--wrong: #f83d56; // 错误选项的颜色
--wrong-bg: rgba(248 ,61, 86,0.8);
--correct-bg: rgb(94 ,210, 53,0.8);
}

input[data-correct="false"]:checked + label{
color: #fff;
background-color: var(--wrong);
border-color: var(--wrong);
}
input[data-correct="true"]:checked + label{
color: #fff;
background-color: var(--correct);
border-color: var(--correct);
}

选择错误选项时,标红标题; 这里用 :has 选择器获取子元素中有错误选项选中时。

1
2
3
4
5
6
css复制代码.question:has(input[data-correct="false"]:checked) {
.questionHeader {
box-shadow: inset 0 7px 0 0 var(--wrong);
background-color: var(--wrong-bg);
}
}

并且给正确选项增加提示动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
css复制代码.question:has(input[data-correct="false"]:checked) {
input[data-correct="true"] + label {
animation: flash 2s infinite;
}
}

@keyframes flash {
0% {
background-color: white;
}
25% {
background-color: #5ed235;
}
50% {
background-color: white;
}
75% {
background-color: #5ed235;
}
100% {
background-color: white;
}
}

选择正确选项时,标绿标题;

1
2
3
4
5
6
css复制代码.question:has(input[data-correct="true"]:checked) {
.questionHeader {
box-shadow: inset 0 7px 0 0 var(--correct);
background-color: var(--correct-bg);
}
}

完整示例

总结

本文介绍了 :has() 选择器的基本用法以及四个实际应用;

  • 选择父元素和前面的兄弟元素
  • :has() 选择器中的 且 和 或
  • 选择一个范围内的元素

在 :has() 选择器出来之前,使用 CSS 是无法直接选择到父级元素和前面的兄弟元素的,但 :has() 选择器的出现使这个变成了可能;

如果对本文感兴趣或对你有帮助,麻烦动动你们的发财手,点点赞~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java多线程面试系列——为什么需要多线程

发表于 2024-03-24

在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第一篇,介绍多线程的目的、多线程编程会出现问题的原因以及解决方式。

  • Java多线程面试系列——为什么需要多线程 - 掘金 (juejin.cn)
  • java多线程面试——旧版api - 掘金 (juejin.cn)
  • java多线程面试——新版api - 掘金 (juejin.cn)

为什么需要多线程

我们都知道CPU缓存、内存、IO设备之间读取速度相差非常大。如下图所示,程序的性能受限于IO设备,无论怎么提高CPU、内存的速度都没有用。。为了解决这个问题,操作系统就提出了多进程、多线程的机制,通过分配时间片的方式来提高CPU的利用率。

image.png

上面图片来源每个程序员都应该知道的延迟数字,是2020年的耗时数据。如果需要看最新的数据,可以看伯克利大学制作的网页

面试题1:进程和线程的区别?为什么要有线程,而不是仅仅是用进程?

  • 从概念上说,进程是系统中正在运行的应用程序,而线程是应用程序中的不同执行路径
  • 从目的上说,进程和线程都是为了解决CPU、内存、IO设备之间读取速度相差过大的问题,不过线程的切换比进程更轻量
  • 从开发上说,进程之间不能共享资源,而线程之间是可以共享同一进程的资源

面试题2:假如只有一个cpu,单核,多线程还有用吗 ?

操作系统使用多线程机制是为了解决CPU、内存、IO设备之间读取速度相差过大的问题。就算是只有一个CPU、单核,在处理IO操作时,也可以采用多线程机制来提高CPU的利用率。

多线程编程为什么容易出问题

在多线程编程中,我们遇到的问题都可以归纳到多线程的可见性、原子性、有序性上去。三个特性介绍如下。需要注意想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

  • 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 原子性:即一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

为什么可见性有问题

image.png

为了解决CPU、内存、IO设备之间读取速度相差过大的问题,除了操作系统的多进程、多线程机制外,CPU还增加了缓存,以均衡与内存的速度差异。

在单核时代,每个线程都共有一个缓存,因此不同的线程对变量的操作有可见性。但是在多核时代(如上图所示),每个 CPU 都有自己的缓存(L1和L2),当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存,因此不同的线程对变量的操作就不具有可见性了。

为什么原子性有问题

多线程原子性的问题有两个原因。其一是大部分程序代码的执行不是原子性的。比如num++(num为0)这条代码需要三条CPU指令:步骤1:把变量 num 从内存加载到 CPU 的寄存器;步骤2:在寄存器中执行 +1 操作;步骤3:将结果写入内存。其二是线程的切换,当线程1执行到步骤1时,这时线程1时间片用完了;如果此时还有线程2,它也执行了步骤1;这时两个线程执行的结果为 1,而不是2.

为什么有序性有问题

1
2
3
4
ini复制代码int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序。指令重排序是指编译器为了优化性能,它有时候会改变程序中语句的先后顺序。需要注意指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

如何解决可见性、原子性、有序性的问题

在Java中,通过定义了JMM(Java内存模型)来解决这个问题。JMM主要有两个作用:

功能一:使java程序在各种平台下都能达到一致的并发效果。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。使用java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的并发效果。

具体模型如下图,Java内存模型规定所有的变量都存储在主内存中,包括实例变量,静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。

JMM

功能二:保证代码的原子性,可见性,有序性。JMM定义了volatile、synchronized 和 final 三个关键字,以及八项Happens-Before 规则的规范来解决可见性、原子性、有序性的问题。需要注意JMM只是定义规范,具体实现是由JVM完成的。

八项happens-before原则:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

可见性、原子性、有序性的问题的解决方式

java多线程.png

  • 解决可见性问题

如上图所示,java的 volatile、final、synchronized 关键字都可以实现可见性。

  1. 被 volatile 修饰的变量,它的值被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。通过这种方式保证可见性。
  2. synchronized 包裹的代码块或者修饰的方法,在执行完之前,会将共享变量同步到主内存中,从而保证可见性。
  3. 被final修饰的字段,初始化完成后对于其他线程都是可见的。需要注意的是,如果final修饰的是引用变量,对它属性的修改是不可见的。
  • 解决有序性问题
  1. volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
  2. 如果你了解过DCL单例模式,应该知道synchronized内部的代码是会指令重排序的。那为什么说synchronized能保证有序性呢?因为synchronized保证的有序性是指它修饰的方法或者代码块内部的代码,经过重排序不会在锁外,而不是确保synchronized内部的有序性
  • 解决原子性问题

synchronized包裹的代码块或者修饰的方法保证了只有一个线程执行,确保了代码块或者方法的原子性。

内存屏障是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

面试题3:sychronied修饰普通方法和静态方法的区别?

使用synchronied修饰普通方法,等价于synchronized(this){},是给当前类的对象加锁;使用synchronied静态方法,等价于synchronized(class){},是给当前类对象加锁。需要注意,synchronized不可以修饰类的构造方法,但是可以在构造函数里面使用synchronied代码块。

面试题4:构造函数为什么不需要synchronized修饰方法?构造函数是线程安全的吗?

在java中,我们是通过new关键字来获取对象。如果多线程执行new,每个线程都会获取一个对象,因此构造函数不需要synchronized来修饰。

但是构造函数是线程安全的吗?答案是不安全的。原因有两个:

  1. 构造函数内部会指令重排序,比如构造函数内部的变量经过指令重排序,其位置可能在构造函数之外。
  2. 创建对象的指令不是原子性的,可能因为指令重排序造成各种问题

面试题5:volatile关键字做了什么?

volatile关键字保证内存可见性和禁止了指令重排。

volatile修饰的变量,它的值被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值

volatile修饰的变量禁止了指令重排序。volatile修饰的变量,在读写操作指令前后会插入内存屏障,这样指令重排序时就不会把后面的指令重排序到内存屏障前

面试题6:DCL中单例成员为什么需要加上volatile关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class SingletonClass {

private volatile static SingletonClass instance = null;

private SingletonClass() {
}

public static SingletonClass getInstance() {
if (instance == null) {
synchronized (SingletonClass.class) {
if(instance == null) {
instance = new SingletonClass();
}
}
}
return instance;
}

}

这是因为创建对象的指令不是原子性的,有三步

  1. 分配内存
  2. 初始化对象
  3. 将内存地址赋值给引用

如果发生了指令重排可能会导致第二步内容和第三步内容顺序发生变化,即还没初始化的对象已经赋值给引用。此时另一个线程会获取还没有初始化的对象,这时对对象的操作可能会造成各种问题。

面试题7:volatile和synchronize有什么区别?

  • volatile 只能作用于变量,synchronized 可以作用于变量、方法。
  • volatile 只保证了可见性和有序性,无法保证原子性,synchronized 可以保证有序性、原子性和可见性。
  • volatile 不阻塞线程,synchronized 会阻塞线程

面试题8:为什么局部变量是线程安全的

如上图所示,局部变量都是放到了java调用栈里,而每个线程都有自己独立的调用栈。

参考

  • 【建议收藏】106道Android核心面试题及答案汇总(总结最全面的面试题)
  • 面试官问我什么是JMM - 知乎 (zhihu.com)
  • Java 并发编程实战 (geekbang.org)
  • final保证可见性和this引用逃逸 - 知乎 (zhihu.com)
  • Synchronized的底层实现原理(原理解析,面试必备)_synchronized底层实现原理-CSDN博客
  • 线程间到底共享了哪些进程资源 - 知乎 (zhihu.com)
  • stackoverflow.com/questions/1…
  • spotcodereviews.com/articles/co…
  • Linux内核同步机制之(三):memory barrier (wowotech.net)
  • 万字长文!一文彻底搞懂Java多线程 - 掘金 (juejin.cn)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

修仙么朋友-从0到1构建个人修仙百科

发表于 2024-03-22

ChatGPT构建修仙百科

ChatGPT非常适合进行日常的通用问答,但在需要领域特定知识时存在一些不足。此外,它会编造答案来填补其知识空白,并且从不引用其信息来源,因此无法真正被信任。

本应用尝试使用网络修仙小说来构建修仙领域的修仙百科。

使用的技术:

  • Next.js (React框架)
  • MemFire (国内版Supabase,使用他们的pgvector实现作为向量数据库)
  • OpenAI API (用于生成嵌入和聊天完成)
  • TailwindCSS (用于样式)

后续工作:

  • 使用MemFire 云函数构建本应用中的api接口
  • 使用MemFire 静态托管部署本应用

功能概述

创建和存储嵌入:

  • 上传修仙小说,转换为纯文本并分割成1000个字符的文档
  • 使用OpenAI的嵌入API,利用”text-embedding-ada-002”模型为每个文档生成嵌入
  • 将嵌入向量存储在Supabase的postgres表中,使用pgvector; 表包含三列: 文档文本、源URL以及从OpenAI API返回的嵌入向量。

响应查询:

  • 从用户提示生成单个嵌入向量
  • 使用该嵌入向量对向量数据库进行相似性搜索
  • 使用相似性搜索的结果构建GPT-3.5/GPT-4的提示
  • 然后将GPT的响应流式传输给用户。

修仙体验

  1. 上传小说
    zhuxian
    fanren
  2. 修仙问答
    xx1
    xx2
    xx3

体验地址

入门指南

以下设置指南假定您至少对使用React和Next.js开发Web应用程序有基本的了解。熟悉OpenAI API和Supabase会对使事情正常运行有所帮助,但不是必需的。

设置Supabase

  1. 登录MemFire 创建应用
    yy1
  2. 启用Vector扩展
    首先,我们将启用Vector扩展。可以在应用的SQL执行器中运行以下命令完成此操作:
1
sql复制代码create extension vector;

yy2

  1. 接下来,让我们创建一个表来存储我们的文档及其嵌入。

转到SQL编辑器并运行以下查询:

1
2
3
4
5
6
sql复制代码create table documents (
id bigserial primary key,
content text,
url text,
embedding vector (1536)
);

yy3

  1. 最后,我们将创建一个用于执行相似性搜索的函数。转到SQL编辑器并运行以下查询:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
sql复制代码create or replace function match_documents (
query_embedding vector(1536),
similarity_threshold float,
match_count int
)
returns table (
id bigint,
content text,
url text,
similarity float
)
language plpgsql
as $$
begin
return query
select
documents.id,
documents.content,
documents.url,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where 1 - (documents.embedding <=> query_embedding) > similarity_threshold
order by documents.embedding <=> query_embedding
limit match_count;
end;
$$;

yy4

设置本地环境

  • 安装依赖项
1
bash复制代码npm install
  • 在根目录中创建一个名为.env.local的文件以存储环境变量:
1
bash复制代码cp .env.local.example .env.local
  • 打开.env.local文件,添加您的Supabase项目URL和API密钥。
    yy5
  • 将您的OpenAI API密钥添加到.env.local文件。您可以在OpenAI Web门户的API Keys下找到它。API密钥应存储在OPENAI_API_KEY变量中。
  • [可选]提供OPEAI_PROXY环境变量以启用您自定义的OpenAI API代理。将其设置为 "" 以直接调用官方API。
  • 启动应用程序
1
bash复制代码npm run dev
  • 在浏览器中打开http://localhost:3000查看应用程序。

github地址

github.com/TiannV/ai-k…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JAVA中常用开源GIS库,你用过几个? 一、JTS 二、G

发表于 2024-03-22

作者:取个名字叫狗哥

在Java开发领域,开源GIS(地理信息系统)库扮演着至关重要的角色,为开发者提供了丰富的工具和框架来处理地理空间数据、构建地图应用以及进行复杂的地理分析。这些库不仅遵循行业标准,如OGC(开放地理空间联盟)制定的一系列规范,还具备高度的可定制性和扩展性,降低了地理信息系统开发的门槛。

Java开源GIS库以其卓越的技术贡献和活跃的社区支持,在地理信息科学和技术行业中具有广泛的影响力和应用价值。开发者可以根据项目需求灵活选择和集成这些库,以构建出强大而高效的应用系统。在此我们分享4个常见的开源GIS 平台及软件,看看你用过几个?

一、JTS

JTS Topology Suite (JTS)是一个开源的Java软件库,它提供了平面几何的对象模型和基本的几何函数,符合OGC发布的“Simple Features for SQL”(SFSQL)规范。JTS被设计用作基于矢量地理信息软件的核心组件,还可以用作计算几何的通用算法库。

image.png

JTS依赖 图源@取个名字叫狗哥

几何关系判断

功能 描述
相等(Equals) 几何形状拓扑上相等
不相交(Disjoint) 几何形状没有共有的点
相交(Intersects) 几何形状至少有一个共有点
接触(Touches) 几何形状有至少一个公共的边界点,但是没有内部点
交叉(Crosses) 几何形状共享一些但不是所有的内部点
内含(Within) 几何形状A的线都在几何形状B内部
包含(Contains) 几何形状B的线都在几何形状A内部
重叠(Overlaps) 几何形状共享一部分但不是所有的公共点,而且相交处有他们自己相同的区域

几何关系分析

功能 描述
缓冲区分析(Buffer) 包含所有的点在一个指定距离内的多边形和多多边形
凸壳分析(ConvexHull) 包含几何形体的所有点的最小凸壳多边形
交叉分析(Intersection) A∩B 交叉操作就是多边形AB中所有共同点的集合
联合分析(Union) AUB AB的联合操作就是AB所有点的集合
差异分析(Difference) (A-A∩B) AB形状的差异分析就是A有B没有的所有点的集合

二、GeoTools

Geotools是一个开源的Java库(官网 www.geotools.org ),
用于处理和分析地理空间数据,并提供了一组工具和API,以便在Java应用程序中使用地理空间数据。它是一个成熟的GIS库,具有广泛的功能和支持,可用于多种应用场景,包括地图制作、数据分析、空间查询和可视化等。
Geotools功能丰富其特点有:

  • 支持多种开放标准,如OGC、ISO和OpenGIS等,使其可以与其他GIS和地理信息系统进行交互。
  • 可以处理多种数据格式,包括Shapefile、GeoJSON、KML、GML等。
  • 支持多种数据源,包括文件、数据库、Web服务等。
  • 提供了多种空间分析工具,如缓冲区分析、空间查询、地理编码、空间统计分析等。
  • 支持多种投影和坐标系统,并提供了一些常见的投影和坐标系统的定义。
  • 提供了多种可视化工具,如渲染器、符号化工具、标注等,可以帮助用户创建动态和交互式地图。

maven仓库配置

image.png

maven配置 图源@取个名字叫狗哥

GeoTools POM依赖

image.png

geotools依赖 图源@取个名字叫狗哥

  • gt-shapefile:用于读取和写入Shapefile文件的库。
  • gt-swing:用于创建Swing应用程序的库,包括创建地图框架和显示地图。
  • gt-epsg-hsql:用于提供EPSG投影和坐标系统定义的库。
  • gt-geojson:用于读取和写入GeoJSON文件的库。
  • gt-referencing:用于处理坐标参考系统和投影的库。
  • gt-coverage:用于处理栅格覆盖数据的库。

三、GeoServer

GeoServer 是 OpenGIS Web 服务器规范的 J2EE 实现,利用 GeoServer 可以方便的发布地图数据,允许用户对特征数据进行更新、删除、插入操作,通过 GeoServer 可以比较容易的在用户之间迅速共享空间地理信息。

GeoServer 支持 OGC 标准规范的系列服务,支持 PostgreSQL、MySQL 等数据库,以及ArcSDE、ShapeFile 等中间件和文件资源,能够将网络地图输出为 JPEG、PNG、KML 等多种图片和数据格,支持多种客户端框架,如Openlayers、mapbox等。

image.png

GeoServer界面 图源@取个名字叫狗哥

GeoServer 常用插件

GeoWebCache 是一个开源的瓦片缓存服务器,可以和 GeoServer 配合使用,提高地图的性能和可扩展性。GeoWebCache 支持多种数据源和投影方式,可以缓存各种类型的地图数据。

image.png

图解缓存服务 图源@取个名字叫狗哥

WPS Plugin 是一个开源的 GeoServer 插件,用于支持 WPS(Web Processing Service)标准。它可以将 GeoServer 的数据和功能暴露为 WPS 服务,允许用户通过 Web 接口来执行地理处理任务。

Image Mosaic JDBC Plugin 是一个开源的 GeoServer 插件,用于支持基于 JDBC 数据源的图像镶嵌。它可以从数据库中动态加载图像数据,支持各种类型的数据源和图像格式。

CSS Styling Plugin 是一个开源的 GeoServer 插件,用于支持基于 CSS 样式表的地图渲染。它可以通过简单的 CSS 语法来控制地图的样式和布局,支持各种类型的数据源和图层。

image.png

样式配置 图源@取个名字叫狗哥

1
css复制代码*{ fill: #02C6FF; fill-opacity: 0.7; stroke-width: 0.2; }

GeoServer SLD Styler是一个基于 JavaScript 和 XML 的插件,用于创建和编辑 GeoServer 的 SLD 样式文件。它提供了一个可视化的界面,可以方便地编辑样式,还支持导入和导出样式文件。

GeoServer Vector Tiles是一个用于创建和发布矢量瓦片的插件,支持各种类型的矢量数据,包括 GeoJSON、KML、WKT 等。它可以方便地将矢量数据发布为矢量瓦片服务,以提高数据加载和渲染的效率。

GeoServer-Manager是一个 Java 库,用于管理 GeoServer 的配置和数据。它提供了一些简单易用的 API,可以用于添加、删除和修改 GeoServer 中的图层、工作区、样式、数据存储等。
GeoServer-StyleEditor是一个 Web 应用程序,用于编辑 GeoServer 样式。它提供了一些可视化工具和编辑器,可以快速创建和修改样式,并实时预览效果。

image.png

GeoServer-StyleEditor 图源@取个名字叫狗哥

GeoServer-FeatureInfo 用于增强 WMS GetFeatureInfo 请求的功能。它可以将 GetFeatureInfo 请求的结果以表格形式呈现,支持自定义样式和排序,并提供了一些钩子函数和 API,可以用于扩展功能和定制化开发。

App-Schema一个开源的数据转换和发布框架,可以将非空间数据转换为空间数据,并发布到 GeoServer 上。它支持各种数据源和格式,包括 XML、JSON、CSV 等。

GeoServer-Printing用于生成高质量的地图打印输出。它支持自定义地图布局、比例尺、图例、文本注记等功能,并提供了多种输出格式和打印选项。

四、uDig

uDig是Geotools 的延伸项目,一个 open source (EPL and BSD) 桌面应用程序框架,构建在Eclipse RCP和GeoTools上的桌面GIS(地理信息系统);是一款开源桌面GIS软件,基于Java和Eclipse平台,可以进行shp格式地图文件的编辑和查看;是一个开源空间数据查看器/编辑器,对OpenGIS标准,关于互联网GIS、网络地图服务器和网络功能服务器有特别的加强。uDig提供一个一般的java平台来用开源组件建设空间应用。

网址是:udig.refractions.net/download/

image.png

uDig 图源@取个名字叫狗哥

关注Mapmost,持续更新GIS、三维美术、计算机技术干货

Mapmost是一套以三维地图和时空计算为特色的数字孪生底座平台,包含了空间数据管理工具(Studio)、应用开发工具(SDK)、应用创作工具(Alpha)。平台能力已覆盖城市时空数据的集成、多源数据资源的发布管理,以及数字孪生应用开发工具链,满足企业开发者用户快速搭建数字孪生场景的切实需求,助力实现行业领先。

欢迎进入官网体验使用:Mapmost——让人与机器联合创作成为新常态

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。微信公众号:Mapmost

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

大模型06-大模型应用开发之准备篇(OpenAI的plugi

发表于 2024-03-21

写在前面的话

在前面几个章节,我们在熟悉了大模型的一些概念,学习了大模型的提示工程,体验了Chat的使用。

今天这个章节是为了接下来我们使用大模型做应用开发做准备。

大家陆续在很多文章中可能已经看到过了很多概念,比如plugins、GPTs、Agent,这里面自ChatGPT3.5爆火以来,有众多的AI创业公司从不同的切入点在做,OpenAI公司也在不断推进自家产品的核心能力和生态的迭代更新,比如去年最引人瞩目的GPT-4 turbo的发布。

接下来我们就以OpenAI的产品来介绍下这些概念,以及在GPT-4 turbo中出现的GPTs等的解读和应用。

OpenAI的plugins与GPTs(Actions)

ChatGPT的”Actions”与”Plugins”是OpenAI在GPT模型中引入的两种不同的功能扩展机制。这两种机制的目的是增强模型的功能,使其能够处理更多样化的任务和请求。

plugins的始末

plugins的推出

2023年(北京时间)3月24凌晨,OpenAI宣布,ChatGPT中初步实现对插件的支持。 (Plugins are tools designed specifically for language models with safety as a core principle, and help ChatGPT access up-to-date information, run computations, or use third-party services.)

推出以来,开发者们已经构建了数千个ChatGPT插件,包括来自Expedia、Instacart和Kayak等公司的插件。

v2-96e45676350d3ab0602d2f3f22592faa_1440w.webp

简单点说,插件能够使ChatGPT参与开发者定义的API互动,增强ChatGPT的能力。有点类似于苹果的appstore。

引入插件Plugin标志ChatGPT走在创建生态系统的伟大道路上,统一平台+插件的模式有望构建与苹果+App Store相似的繁荣生态。

同时,Plugin的推出给了广大的开发者更多的参与大模型的机会。

再见,ChatGPT插件,Hello,GPTs

image.png

image.png

自2024年3月19日起,将不再允许创建带插件的新对话,这一变化意味着,用户和开发者将无法安装新插件或利用现有插件创建新的对话。

为什么要结束插件测试版?

官方的回答是:

“With the launch of GPTs and the GPT store, we were able to make many improvements that plugin users had asked for. GPTs now have full feature parity (in addition to many new features) with plugins.”

即“随着 GPTs 和 GPT 商店的推出,我们能够根据插件用户的要求进行许多改进。GPTs 现在具有与插件相同的完整功能(除了许多新功能之外)。”

GPTs的推出,使得普通人也能在GPT的辅助下自己创建智能体(尽管因每个人的能力不同,创建的智能体的能力强弱也各有差异)。

如果你在微博上搜索 ChatGPT Plugins,热门内容还是一年前的那场发布会演示和评论,所有人都在感叹向第三方开放 ChatGPT 的能力有多逆天,结果 OpenAI 自己就用 GPTs 把 ChatGPT Plugins 给干掉了。

具体为什么OpenAI会在一年以后关闭Plugins,网上有很多推论,可能和OpenAI的布局有关,也可能和目前的安全有关。但是这都不妨碍原本的很多插件也都开发了GPTs,很多GPTs已经可以替代绝大部分插件的功能了。GPT商店已经拥有数十万个GPTs,涵盖写作、生产力、编程、教育等类别。

GPTs的推出使得非开发者的普通人,也可以投入到大模型的浪潮。

二者的对比

ChatGPT Plugins 是需要本地开发的,API 权限卡得特别严格,但大多数 GPTs 却都是直接基于 Web 端创建的,甚至不需要编程。

Plugins

定义与用途:Plugins(插件)是一种用于扩展ChatGPT功能的机制,允许模型与外部系统交互。例如,可以与数据库、API或其他软件服务进行交互。

工作方式:当ChatGPT需要获取外部信息或执行某些不仅仅依赖文本生成的任务时,会通过这些插件与外部系统通信。

应用实例:例如,ChatGPT可以通过一个天气插件来获取实时天气信息,或者通过搜索引擎插件来提供最新的搜索结果。

Actions

定义与用途:Actions(动作)是ChatGPT的一种新功能,旨在允许模型在对话中直接执行特定的动作,这些动作可能涉及模型的内部功能或特定的任务执行。

新特性:
直接交互:Actions可以让模型在对话中直接触发和执行特定任务,如生成图像、执行代码等,无需外部插件介入。
任务多样性:支持多种不同的任务类型,如文本生成、图像处理、数据分析等。

更流畅的用户体验:通过Actions,用户体验更加直接和流畅,不需要离开对话界面即可完成多种任务。

自定义动作:支持创建自定义动作,以适应特定的用例或需求。

集成内部工具:与内置的工具和功能(如Python环境、DALL-E图像生成等)紧密集成。

总结

Plugins:侧重于与外部系统的交互和集成。
Actions:侧重于在对话中直接执行特定任务,提供了更多样化的内部功能。

GPT4.0 turbo升级上线

提到GPTs就不得不提GPT4.0 turbo,它的出现,是使得众多AI创业者一夜无眠的版本。

借用一个图来说明下它的强大:

image.png

  • 这里面有几个爆点:
  • 更长。支持128K上下文输入,标准GPT-4是8K版本,之前升级出了32K版本
  • 更可控。JSON格式输出,增加seed控制模型回复可复现
  • 更新的知识。GPT-4 Trubo的知识更新至2023年4月
  • 开放多模态能力,整合了文生图模型DALL·E 3和声音合成模型(TTS)以及语音识别模型Whisper V3等
  • 开放 Fine-Tuning功能,支持在GPT-4基础上微调进行模型定制
  • 输出速度更快,每分钟输出翻倍
  • GPTs
  • Assistant API

基于大模型(LLM)的Agent

目前,业界一般认为基于大模型的应用集中在两个方向上:RAG 和 Agent,无论哪一种应用,设计、实现和优化能够充分利用大模型(LLM)潜力的应用都需要大量的努力和专业知识。

Agent广义的定义

这里的Agent 指的是智能体,可以追溯到明斯基的《society of mind》一书。在那本书中,明斯基对Agent的定义有点抽象——“社会中某个个体经过协商后可求得问题的解,这个个体就是agent”。在计算机领域,agent是一种通过传感器感知其环境,并通过执行器作用于该环境的实体,因此,可以把实体定义为一种从感知序列到实体动作的映射。一般认为,Agent是指驻留在某一环境下,能持续自主地发挥作用,具备自主性、反应性、社会性、主动性等特征的计算实体。

智能,是Agent 与环境相互作用的涌现属性。

大模型中的Agent

在大模型领域,大模型替代了传统agent 中的规则引擎以及知识库,Agent提供了并寻求推理、观察、批评和验证的对话通道。特别是当配置了正确的提示和推理设置时,单个LLM就可以显示出广泛的功能 ,不同配置的Agent之间的对话可以帮助以模块化并以互补的方式将这些广泛的LLM功能结合起来。

开发人员可以轻松、快速地创建具有不同角色的Agent,例如,使用Agent来编写代码、执行代码、连接人工反馈、验证输出等。通过选择和配置内置功能的子集,Agent的后端也可以很容易地进行扩展,以允许更多的自定义行为。

基于大模型的常见Agent 和 Multi-Agent 系统

小结

开头我们讲了,Agent是一个目前大模型领域重要的应用方向,接下来我们也会再细分专题分别进行讨论。不在这里过多展开。

OpenAI推出的Assistants API

Assistant全名Assistant API, 所以它本身不是一个APP,而是API工具箱,可以嵌入到APP中那种,所以Assistant API的应用层级应该是介于Fine-tuned Models和LLM-based APP之间,它看起来不是一个完全体应用,但也不需要像模型finetune一样需要掌握原理、数据集、方法等。

这里后面我们也单独开个专题来分享对于Assistants API的使用。

RAG

所谓RAG,检索增强生成(Retrieval Augmented Generation),简称 RAG,已经成为当前最火热的LLM应用方案。

通俗点说;就是通过自有垂域数据库检索相关信息,然后合并成为提示模板,给大模型生成漂亮的回答。

RAG的出现,是因为在大模型的广泛应用中,伴随着出现的一些问题,比如:

  • 知识的局限性:模型自身的知识完全源于它的训练数据,而现有的主流大模型(ChatGPT、文心一言、通义千问…)的训练集基本都是构建于网络公开的数据,对于一些实时性的、非公开的或离线的数据是无法获取到的,这部分知识也就无从具备。
  • 幻觉问题:所有的AI模型的底层原理都是基于数学概率,其模型输出实质上是一系列数值运算,大模型也不例外,所以它有时候会一本正经地胡说八道,尤其是在大模型自身不具备某一方面的知识或不擅长的场景。而这种幻觉问题的区分是比较困难的,因为它要求使用者自身具备相应领域的知识。
  • 数据安全性:对于企业来说,数据安全至关重要,没有企业愿意承担数据泄露的风险,将自身的私域数据上传第三方平台进行训练。这也导致完全依赖通用大模型自身能力的应用方案不得不在数据安全和效果方面进行取舍。

而RAG是解决上述问题的一套有效方案。

RAG = 检索技术 + LLM 提示。例如,我们向 LLM 提问一个问题,RAG 从各种数据源检索相关的信息,并将检索到的信息和问题注入到 LLM 提示中,LLM 最后给出答案。

许多产品基于 RAG 构建,从基于 web 搜索引擎和 LLM 的问答服务到使用私有数据的chat应用程序。

总结

本文章讲了很多在大模型应用层的很多概念,有ChatGPT的功能:plugins、GPTs、Assistant API;也有大模型的应用:agent,RAG,提示词工程的应用,finetune,训练垂直领域大模型,自己造轮子等等。

在真正聚焦于我们找到自己感兴趣和合适的深入方向之前,我们要了解现在基于大模型的应用都有些什么,才能知道我们在什么场景下,能做些什么。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

10 天的开发量,老板让我 1 天完成,怎么办?

发表于 2024-03-20

大家好,我是树哥!

昨天,我在文章《业务开发做到零 bug 有多难?》和大家聊了下影响零 bug 的一些因素。其中,我提到了开发时被压缩工时,应该怎么做。今天,我们就来聊聊这个话题。

只要工作过几年的小伙伴,必然会遇到过背压工时的情况。面对这种情况,不同的工作年限、在不同的公司、不同的团队氛围下,都会有不同的反应。如果你是一个刚刚毕业的萌新开发,很大情况下你会选择自己加班服从。甚至加班都完不成的情况下,你还吭哧吭哧不出声。

最后等待你的结果就是 —— 成为被复盘的对象,被批评。那么如果遇到了开发时间被压缩,或者被质疑的情况下,我们除了默默加班接受之外,还能做些什么来让自己没那么苦逼吗?

在我看来,自己一个人傻傻加班是下下签,是最后实在没办法才做的无奈之举。一旦有其他选择,你都不应该提出自己加班加点做完。那么,到底有什么办法可以解决工时被压缩这一问题呢?

解释工时构成

如果你的开发时间被压缩,那么较大可能是 leader 质疑你评估出的工时。假设你的工时评估并没有问题,那么就是你考虑到了一些风险点,而你的 leader 并没有考虑到。毕竟这也很正常,对于一个很久没有写代码的管理者来说,其会习惯性地忽略一些细节性的东西。

这个时候,你要做的不是胆怯地接受。而是要主动去找 leader ,跟他解释工时是怎么评估出来的。你考虑到了某些风险点,为什么是这么多工时。如果你的 leader 不是傻子,那么相信他会接受你的解释。但这里要注意的是,解释的时候记得要语气好些,不要怒气冲冲地找别人,不然话没说然就吵起来了。

减少需求内容

假设你和 leader 已经进行了友好地沟通, leader 也认可了你的评估时间。但是他说:没办法,老板就要求那个时间点做完,没办法给你更多时间了!

这时候,萌新小白就会老老实实回去座位上加班,最后还是干不完被批斗。但对于职场老油条来说,他就学会与 leader 以及产品沟通 —— 能不能少点需求内容。例如:我们要做一个员工列表,那是不是只做列表就可以,不用做筛选和搜索了?员工详情是不是也可以先不走了?

老板可以指定最终完成的时间,但是他基本不会干涉到具体的细节上面。这时候就给我们留下了沟通的空间,这也就是作为开发的你可以争取的东西。对于一个有经验的产品经理来说,如果研发给他提出了减少非核心功能的诉求,他一般也会答应的。

申请更多资源

如果产品说:不行,我们每个功能点都得做,一点需求都少不了!

这时候你可以再向你的老板提出诉求 —— 申请更多资源。

前面你也解释过工时的构成,做这么多功能确实需要这么多时间。如果最终上线时间不能推迟,那么就只能投入更多的资源了。

在这种情况下,如果公司还有富裕的研发资源,那自然会优先考虑你这边的诉求。对于你来说,你的研发压力也自然变小了。

分摊开发压力

如果实在又申请不到更多资源,这个项目又只能由你们团队 5 个人来完成,怎么办?

很多时候,不同开发人员的开发压力不一样。可能你开发压力比较大,其他人开发压力比较小,这时候你可以提出 —— 是否可以让其他小伙伴帮忙做点工作,这样可以减少一些压力?

我想,如果你的 leader 也应该会考虑你的诉求。千万不要自己明明完成不了,还要硬抗。到最后加班干了几个星期,需求还是完成不了,不仅辛苦的付出得不到理解,还被批斗。那可就真的是赔了夫人又折兵啊!

千万不要觉得这种情况不会发生,在我去年工作的时候,就发生了这样一个事情,我也是很同情那位同学的。如果那位同学能看到这篇文章,那么或许他后面就不会踩坑了吧。

推迟上线时间

上面说得是最终上线时间无法变更的情况,但很多时候并没有这种倒排需求。很多需求的上线时间并不是一成不变的,你只要给出足够合理的解释,也都是可以沟通的。

因此,如果在上面的沟通方法都行不通的情况下,你也可以沟通看看是否可以推迟上线时间。毕竟相对于研发速度来说,研发质量肯定更加重要。

终极绝招

大多数情况下,如果你能合理应用上面提到的几种沟通方式,被压缩工时的问题一般都能解决。但有些小伙伴会问:那如果真的所有办法都失效了呢?那怎么办?

其实,大多数情况下,不太可能到了需要使用绝招的地步。但如果真的到了这一步,那你就做好「殊死一搏」的准备,用上这个绝招吧 —— 调预期、表态度。

调预期,就是给你的 leader 打预防针,提前告诉他这样做的后果就是 —— 质量差、很难做得完。如果他还是这么坚定地推进,那么如果真的做不完,相信他也理解,不会太过于责怪你。

表态度,就是得加加班。如果你之前已经说了压力很大,甚至加班都做不完。那么你至少还是得表表态度,不能像往常一样早早下班,这样即使最后搞砸了。由于你态度还算端正,还不至于被责怪得太狠。但如果你要是又说做不完,又每天早早下班,那别人就觉得是你态度问题了。

走到这一步,实属是无奈,但这也是最后的保命之举了,除非你不想在这干了。

总结

今天分享了几种沟通解决「被压缩工时」的方法,包括:

  • 解释工时构成
  • 减少需求内容
  • 申请更多资源
  • 分摊开发压力
  • 推迟上线时间

本质上来说,就是不要自己一个人傻傻地抗压力,不要让自己背负着太大压力。我们要明白自己能做到什么程度,而且不要早早把「自己加班」这一最后的保命、卖惨利器祭出。他应该是自己的保命技能,而不是为别人锦上添花的技能。

特别是,不要因为赶进度、赶工时,而去牺牲开发质量。因为如果你这么做了,后果就是你付出了很多时间和精力,最后你会在项目复盘会上检讨 —— 为什么你的功能代码质量这么差。这是另一个话题了,后续有时间我们继续聊。

希望大家都能够活学活用,下次在和 leader 以及产品沟通的时候,用上这些沟通技巧吧!希望大家都不要加班,准时下班!

如果你觉得今天的文章对你有帮助,欢迎点赞转发评论支持树哥,你的支持对于我很重要,感谢大家!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网首发 探秘Flutter UI测试-Golden Tes

发表于 2024-03-19

image.png

前言

UI测试分为两种类型:行为测试和视觉测试。行为测试主要关注用户在操作UI组件时,UI的变化是否正确。而视觉测试则着重于验证UI的外观是否符合预期。通过比较应用的实际截图与预期结果,视觉测试能够确保UI在不同平台、不同设备上的一致性和美观性。

在实际项目开发中,经常会对现有的UI进行功能扩展或修改。例如,在消息通知按钮上添加红点提示功能。这种改动可能会导致原有的UI出现问题,比如间距不一致或字体大小不对等。而验证是否出现该问题需要不少的成本,如果只是细小的差别,如2,3个像素的差别,那肉眼很难鉴别。这时就需要Golden test了。Golden Test正是一种视觉测试工具,它能够帮助我们确认新功能的引入是否影响了UI的整体外观,从而确保应用的质量和用户体验。

什么是Golden Test

Golden Test 是一种通过比较图片来测试 UI 的技术。它首先创建一个标准图片,表示 UI 正确状态。然后,当开发者对 UI 进行修改时,Golden Test 会自动捕获新的 UI 状态,并与标准图片进行比较。通过比较这两张图片,Golden Test 可以发现 UI 的变化,并生成测试报告。因此,Golden Test 也被称为快照测试,它以像素级别的准确性进行比较,即使是微小的像素差异也能被捕捉到。这使得 Golden Test 在视觉测试方面非常可靠,开发者可以放心地使用它来确保应用在不同情况下的一致性和美观性。

Golden Test 在许多平台中都有对应的工具,如Android中有Paparazzi,IOS中有SnapshotTesting, Web中有snapshot-diff 等,不过它们大部分都不是平台官方的工具而是以第三方库的形式存在。而Flutter是少有的提供了Golden Test官方支持的平台。接下来我将讲述如何在flutter中使用Golden Test

如何使用Golden test

在Flutter 项目中的test文件夹里,创建一个golden_widget_test.dart的代码文件。代码如下:

1
2
3
4
5
6
7
dart复制代码void main() {
testWidgets('Golden test', (WidgetTester tester) async {
await tester.pumpWidget(MyApp());
await expectLater(find.byType(MyApp),
matchesGoldenFile('circle_button.png'));
});
}

正如你所看到的,我通过tester.pumpWidget方法渲染一个My App widget ,该widget包含CircleButton

1
dart复制代码await tester.pumpWidget(MyApp());

然后expectLater 验证该widget是否与基准图片(main.png)一致。

1
2
dart复制代码    await expectLater(find.byType(MyApp),
matchesGoldenFile('circle_button.png'));

一开始我们并没有基准图片,所以假定当前Widget的UI是正确的,即通过UI设计师验证过的,则通过运行该命令,生成基准图片。

1
shell复制代码flutter test --update-goldens

运行该命令后,test文件夹出现了circle_button.png ,这就是基准图片。不过这基准图片似乎不太对,对比运行在真机上按钮的字体变成了矩形。

image.png

image.png

circle_button.png

真机运行图片

出现这种情况的原因是不同的设备上运行的字体都是不同的,为了保证Golden Test在不同的设备上运行时生成的图片一致,所以将字体都渲染成了矩形,但是这样我们就无法通过查看图片来验证UI显示是否符合预期了,而且在运行在Ci/Cd机子的设备基本都是统一的。所以并不需要将此渲染成矩形。

这里我通过引入三方库golden_toolkit来解决此问题。golden_toolkit也能帮助开发者更加易于编写Golden Test。它的优势这里不作过多赘述。

引入golden_toolkit后,test文件夹下新建flutter_test_config.dart 。文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dart复制代码import 'dart:async';
import 'dart:io';

import 'package:golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
return GoldenToolkit.runWithConfiguration(
() async {
await loadAppFonts();
await testMain();
},
config: GoldenToolkitConfiguration(
// Currently, goldens are not generated/validated in CI for this repo. We have settled on the goldens for this package
// being captured/validated by developers running on MacOSX. We may revisit this in the future if there is a reason to invest
// in more sophistication
skipGoldenAssertion: () => !Platform.isMacOS,
),
);
}

然后下载第三方字体文件进行导入,这是因为golden_toolkit自带的字体只支持渲染英文。然后在pubspec.yaml 中的flutter 标签下添加如下字符串。

image.png

在实际项目中直接导入字体文件会使包体积增加一些无用文件。而字体暂时不能像dev_dependencies 一样添加只在开发中引用的库。github.com/flutter/flu…

这里我建议在项目中文件夹里再建一个flutter example,在该example里写Golden Test。

这些操作后开始编写基于golden_toolkit的Golden Test。新建circle_button_test。在test文件夹中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
dart复制代码import 'package:flutter_golden_test_example/circle_button.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
testGoldens('Circle button', (tester) async {
final builder = GoldenBuilder.grid(
columns: 3,
widthToHeightRatio: 1,
)
..addScenario(
'alarm',
const CircleButton(icon: 'assets/images/ic_alarm.svg', text: '时钟'),
)
..addScenario(
'notification',
const CircleButton(
icon: 'assets/images/ic_notification.svg', text: '通知'),
);
await tester.pumpWidgetBuilder(builder.build());
await screenMatchesGolden(tester, 'circle_button');
});
}

然后运行flutter test --update-goldens 就会生成如下文件。这就是基准图片。

image.png

image.png
然后我们模拟下对该UI在新增功能时出现的破坏性变更导致文本字体颜色错误。

image.png
此时运行flutter test 。则会报错。图中圈出来的信息则告知了当前UI代码输出的图和基准图的差异比例。并新增了failures文件夹,里面的文件分别表示的作用是:

image.png

image.png

文件后缀名 作用
xx_isolatedDiff 标记两者差异的图片
xx_maskedDiff 标记两者差异的图片
xx_masterImage 基准图片
xx_testImage 被测试图片(当前UI代码生成的图片)

其中isolatedDiff和maskedDiff的差异,直接看图。

image.png

circle_button_isolatedDiff.png

image.png

circle_button_maskedDiff.png

这些Diff的图片会圈出UI差异的地方。

如果你UI的变更是通过合并分支的话,那在你更新基准图片后,提交mr后,mr页面会显示出UI的变更如下图:

image.png

最后记得还需要将 failures 文件夹添加在 .gitignore 中

image.png

相关链接

源代码仓库: github.com/drown0315/f…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

在Jetpack Compose中方便的使用MVI思想?试试

发表于 2024-03-19

原文地址: # 在Compose中方便的使用MVI思想?试试useReducer!

写在前面

本文中提及的use开头的函数,都出自与我的 ComposeHooks 项目,它提供了一系列 React Hooks 风格的状态封装函数,可以帮你更好的使用 Compose,无需关系复杂的状态管理,专心于业务与UI组件。

这是系列文章的第已篇,全部文章:

  • 在Compose中使用useRequest轻松管理网络请求
  • 在Compose中使用状态提升?我提升个P…Provider
  • 在Compose中父组件如何调用子组件的函数?
  • 在Compose中方便的使用MVI思想?试试useReducer!

什么是 MVI?

什么是 MVI?想必你也看过很多博客了,其实简单说就是:明确分离数据模型(Model)、用户界面(View)和用户意图(Intent,也称为事件、动作),以实现UI的响应式和可预测的更新。

它与 MVVM 其实区别不大,有别于 MVVM 的是,MVVM 将耦合代码按照职责区分,拆分文件。借助 LiveData 或者 DataBinding 将 VM 中数据更新直接驱动 V 层,实现了 V 层与 M 层之间的解耦。

得力于 Compose 带来的状态驱动视图能力,我们可以理解 MVI 思想为:用户发出事件,事件驱动状态变化,状态驱动 UI 变化。这也就是所谓的事件向上,状态向下:事件从组件发出,单一可信来源的状态驱动组件更新。

从这一思想出发,我们可以理解为:谁持有状态,谁就是 M 层。那么过去的 MVVM 的文件拆分将会变得相对松散,我们完全可以摒弃过去那种全屏式思想,不再根据屏幕创建一个 VM 大管家。而是拆分成职责、粒度更细小的组件思想。
事件向上状态向下

当然使用 MVVM 我们一样可以做到类似的效果,但是 MVI 将它流程化、标准化,所以可以理解为其实 MVI 就是一个有一定模板的更优秀的 MVVM。

过去我们的 VM 层其实也很重,一个复杂的页面,数个网络接口,都被仍在一个 VM 中,鉴于不同开发者的水平的参差,我们项目中甚至有一个 VM 文件中持有了20多个 LiveData,可以说完全违背了 MVVM 的初衷。

同样的,想必你已经看了很多在 Compose 中使用 ViewModel 来实现 MVI 的文章了吧(我甚至看过回字的四种写法),它真的有这么复杂么?在 Compose 中我们还需要这样的一位大管家么?虽然很多例子、甚至官方的 demo,都还在使用 ViewModel,但是这是一种无法回避的取舍?还是既往路线的惯性。

在一些场景下,我们完全可以更组件化思维,在更小粒度上应用 MVI。今天你可以试试一点新东西:useReducer,通过它我将进一步阐述我所说的:松散的、组件下的 MVI 思想。

我们需要 VM 么?

MVI 相关文章中你可能会看到一个观点:纯函数(给定相同的输入时,总是产生相同的输出,并且不产生任何副作用的函数)。

我们构建一个改变状态的函数,称之为 reducer 函数,将上一个状态、Intent(也成为 event、action)作为函数的入参,将返回值作为新的状态应用于组件,只要这个 reducer 函数是 纯函数,我们就实现了:可预测的更新。

现在我们来构建一个最简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码// 构建状态类型
data class SimpleData(
   val name: String,
   val age: Int,
)

// Intent、我们一半习惯称之为:action,使用 sealed interface可以方便的实现
sealed interface SimpleAction {
   data class ChangeName(val newName: String) : SimpleAction
   data object AgeIncrease : SimpleAction
}

// 构建一个 Reducer 函数,泛型是状态的类型
val simpleReducer: Reducer<SimpleData> = { prevState: SimpleData, action: Any ->
   when (action) {
       is SimpleAction.ChangeName -> prevState.copy(name = action.newName)
       is SimpleAction.AgeIncrease -> prevState.copy(age = prevState.age + 1)
       else -> prevState
  }
}

reducer 函数中我们要使用 不可变 数据,data class 就是最好的选择,通过 copy 函数返回新的状态。

这些代码,要么是类型声明、要么是一个纯函数,他们与最终组件息息相关,但是他们并需要放到一个 ViewModel 类中,再想一想我们需要 ViewModel 么?

上面的代码几乎已经是 MVI 的完整实现了,M层状态:SimpleData,I层由Action、Reducer函数构成。

他们非常简单、容易理解,而且可以方便的扩展,规范了M层变化(你不能直接修改状态,必须通过传递 Action 给 Reducer 函数驱动状态变化)。

再来看看我们的 V 层需要做什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码@Composable
fun UseReducerExample() {
   val (state, dispatch) = useReducer(simpleReducer, initialState = SimpleData("default", 18))
   val (input, setInput) = useState("")
   Surface {
       Column {
           Text(text = "UserName: $state.name")
           Text(text = "UserAge: $state.age")
           OutlinedTextField(value = input, onValueChange = setInput)
           TButton(text = "changeName") {
               dispatch(SimpleAction.ChangeName(input))
          }
           TButton(text = "+1") {
               dispatch(SimpleAction.AgeIncrease)
          }
      }
  }
}

我们只需要使用:useReducer函数,传入 Reducer 函数与一个初始状态:initialState,通过解构声明语法,可以轻松的拿到状态、dispatch函数。

然后在组件中使用即可。

我们不一定需要 VM!

现在我可以回答我之前的问题了,我们真的需要么?

很多场景我们其实并不需要,将状态从 VM 中拆解、粒化成为更小的一个个组件,在组件文件中直接声明这些状态类型、Action、Reducer 函数,然后通过 useReducer 函数即可。

在大多数场景也许我们根本就用不上 VM 带给我们的好处,它在 View 体系是那么重要,但是在 Compose 中,我认为有点可有可无了。

比如生命周期感知,除非你要使用旋转屏幕下的状态保持(大多数应用都是锁方向的)?

比如数据共享,了解一下状态提升?了解一下 useContext?

如果你是旧的 MVVM 项目改造,那么使用 vm 改造成本比较小,如果你是新项目,我觉得一般的场景完全没有必要继续使用 VM 了。

探索更多

项目开源地址:junerver/ComposeHooks

MavenCentral:hooks

1
scss复制代码implementation("xyz.junerver.compose:hooks:1.0.6")

欢迎使用、勘误、pr、star。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…464748…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%