2024年1月

本文分享自华为云社区《
Golang生成随机字符串的八种方式与性能测试
》,作者: 张俭。

前言

这是**
icza
**在StackOverflow上的一篇高赞
回答
,质量很高,翻译一下,大家一起学习

问题是:go语言中,有没有什么最快最简单的方法,用来生成只包含英文字母的随机字符串

icza给出了8个方案,最简单的方法并不是最快的方法,它们各有优劣,末尾附上性能测试结果:

1. Runes

比较简单的答案,声明一个rune数组,通过随机数选取rune字符,拼接成结果

package approach1

import (
"fmt" "math/rand" "testing" "time")var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func randStr(n
int) string{
b :
=make([]rune, n)for i :=range b {
b[i]
=letters[rand.Intn(len(letters))]
}
return string(b)
}

func TestApproach1(t
*testing.T) {
rand.Seed(time.Now().UnixNano())
fmt.Println(randStr(
10))
}

func BenchmarkApproach1(b
*testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

2. Bytes

如果随机挑选的字符只包含英文字母,我们可以直接使用bytes,因为在UTF-8编码模式下,英文字符和Bytes是一对一的(Go正是使用UTF-8模式编码)

所以可以把

var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

用这个替代

var letters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

或者更好

const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

现在我们有很大的进展了,我们把它变为了一个常数,在go里面,只有string常数,可并没有slice常数。额外的收获,表达式
len(letters)
也变为了一个常数(如果
s
为常数,那么
len(s)
也将是常数)

我们没有付出什么代码,现在
letters
可以通过下标访问其中的bytes了,这正是我们需要的。

package approach2

import (
"fmt" "math/rand" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"func randStr(nint) string{
b :
= make([]byte, n)for i :=range b {
b[i]
=letters[rand.Intn(len(letters))]
}
return string(b)
}

func TestApproach2(t
*testing.T) {
rand.Seed(time.Now().UnixNano())

fmt.Println(randStr(
10))
}

func BenchmarkApproach2(b
*testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

3. Remainder 余数

上面的解决方法通过
rand.Intn()
来获得一个随机字母,这个方法底层调用了
Rand.Intn()
,然后调用了
Rand.Int31n()

相比于生成63个随机bits的函数
rand.Int63()
来说,
Rand.Int31n()
很慢。

我们可以简单地调用
rand.Int63()
然后除以
len(letterBytes)
,使用它的余数来生成字母

package approach3

import (
"fmt" "math/rand" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"func randStr(nint) string{
b :
= make([]byte, n)for i :=range b {
b[i]
= letters[rand.Int63() %int64(len(letters))]
}
return string(b)
}

func TestApproach3(t
*testing.T) {
rand.Seed(time.Now().UnixNano())

fmt.Println(randStr(
10))
}

func BenchmarkApproach3(b
*testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

这个算法能正常工作并且非常快,不过它牺牲了部分精确性,字母出现的概率并不是精确一样的(假设
rand.Int63()
生成63比特的数字是等概率的)。由于字母总共才52个,远小于 1<<63 - 1,因此失真非常小,因此实际上这完全没问题。

解释: 假设你想要0~5的随机数,如果使用3位的bit,3位的bit等概率出现0~7,所以出现0和1的概率是出现2、3、4概率的两倍。使用5位的 bit,0和1出现的概率是
6/32
,2、3、4出现的概率是
5/32
。现在接近了一些了,是吧?不断地增加比特位,这个差距就会变得越小,当你有63位地时候,这差别已经可忽略不计。

4. Masking 掩码

在上一个方案的基础上,我们通过仅使用随机数的最低n位保持均匀分布,n表示所有字符的数量。比如我们有52个字母,我们需要6位(52 = 110100b)。所以我们仅仅使用了
rand.Int63()
的最后6位。并且,为了保持所有字符的均匀分布,我们决定只接受在
0..len(letterBytes)-1
的数字即0~51。(译者注:这里已经没有第三个方案的不准确问题了)

最低几位大于等于
len(letterBytes)
的概率一般小于
0.5
(平均值为0.25),这意味着出现这种情况,只要重试就好。重试n次之后,我们仍然需要丢弃这个数字的概率远小于0.5的n次方(这是上界了,实际会低于这个值)。以本文的52个字母为例,最低6位需要丢弃的概率只有
(64-52)/64=0.19
。这意味着,重复10次,仍然没有数字的概率是1*10^-8。

package approach4

import (
"fmt" "math/rand" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const(//6 bits to represent a letters index letterIdBits = 6 //All 1-bits as many as letterIdBits letterIdMask = 1 <<letterIdBits - 1)

func randStr(n
int) string{
b :
= make([]byte, n)for i :=range b {if idx := int(rand.Int63() & letterIdMask); idx <len(letters) {
b[i]
=letters[idx]
i
++}
}
return string(b)
}

func TestApproach4(t
*testing.T) {
rand.Seed(time.Now().UnixNano())

fmt.Println(randStr(
10))
}

func BenchmarkApproach4(b
*testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

5. Masking Improved

第4节的方案只使用了
rand.Int63()
方法返回的64个随机字节的后6位。这实在是太浪费了,因为
rand.Int63()
是我们算法中最耗时的部分了。

如果我们有52个字母,6位就能生成一个随机字符串。所以63个随机字节,可以利用
63/6=10
次。

译者注:使用了缓存,缓存了
rand.Int63()
方法返回的内容,使用10次,不过已经并不是协程安全的了。

package approach5

import (
"fmt" "math/rand" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const(//6 bits to represent a letter index letterIdBits = 6 //All 1-bits as many as letterIdBits letterIdMask = 1<<letterIdBits - 1letterIdMax= 63 /letterIdBits
)

func randStr(n
int) string{
b :
= make([]byte, n)//A rand.Int63() generates 63 random bits, enough for letterIdMax letters! for i, cache, remain := n-1, rand.Int63(), letterIdMax; i >= 0; {if remain == 0{
cache, remain
=rand.Int63(), letterIdMax
}
if idx := int(cache & letterIdMask); idx <len(letters) {
b[i]
=letters[idx]
i
--}
cache
>>=letterIdBits
remain
--}return string(b)
}

func TestApproach5(t
*testing.T) {
rand.Seed(time.Now().UnixNano())

fmt.Println(randStr(
10))
}

func BenchmarkApproach5(b
*testing.B) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

6. Source

第5个方案非常好,能改进的点并不多。我们可以但不值得搞得很复杂。

让我们来找可以改进的点:随机数的生成源

crypto/rand
的包提供了
Read(b []byte)
的函数,可以通过这个函数获得需要的随机比特数,只需要一次调用。不过并不能提升性能,因为
crypto/rand
实现了一个密码学上的安全伪随机数,所以速度比较慢。

所以让我们坚持使用
math/rand
包,
rand.Rand
使用
rand.Source
作为随机位的来源,
rand.Source
是一个声明了
Int63() int64
的接口:正是我们在最新解决方案中需要和使用的唯一方法。

所以我们不是真的需要
rand.Rand

rand.Source
包对于我们来说已经足够了

package approach6

import (
"fmt" "math/rand" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" var src =rand.NewSource(time.Now().UnixNano())const(//6 bits to represent a letter index letterIdBits = 6 //All 1-bits as many as letterIdBits letterIdMask = 1<<letterIdBits - 1letterIdMax= 63 /letterIdBits
)

func randStr(n
int) string{
b :
= make([]byte, n)//A rand.Int63() generates 63 random bits, enough for letterIdMax letters! for i, cache, remain := n-1, src.Int63(), letterIdMax; i >= 0; {if remain == 0{
cache, remain
=src.Int63(), letterIdMax
}
if idx := int(cache & letterIdMask); idx <len(letters) {
b[i]
=letters[idx]
i
--}
cache
>>=letterIdBits
remain
--}return string(b)
}

func TestApproach6(t
*testing.T) {
fmt.Println(randStr(
10))
}

func BenchmarkApproach6(b
*testing.B) {for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

注意到这里我们没有使用种子初始化rand了,取而代之的是初始化了
rand.Source

还有一件需要注意的事,
math/rand
的文档指出

默认的Source是协程安全的

所以默认的Source比通过
rand.NewSource()
创建出来的
Source
要慢。不用处理协程并发场景,当然慢啦。

7. 使用 strings.Builder

之前的解决方案都返回了通过slice构造的字符串。最后的一次转换进行了一次拷贝,因为字符串是不可变的,如果转换的时候不进行拷贝,就无法保证转换完成之后,byte slice再被修改后,字符串仍能保持不变。

Go1.10引入了strings.Builder,这是一个新的类型,和bytes.Buffer类似,用来构造字符串。底层使用
[]byte
来构造内容,正是我们现在在做的,最后可以通过
Builder.String()
方法来获得最终的字符串值。但它很酷的地方在于,它无需执行刚才谈到的复制即可完成此操作。它敢这么做是因为它底层构造的
[]byte
从未暴露出来,所以仍然可以保证没有人可以无意地、恶意地来修改已经生成的不可变字符串。

所以我们的下一个想法不是在slice中构建随机字符串,而是使用 strings.Builder,结束building后,我们就可以获取并返回结果,而无需复制。 这可能在速度方面有所帮助,并且在内存使用和分配方面肯定会有所帮助(译者注:等会在benchmark中会清晰地看到)。

package approach7

import (
"fmt" "math/rand" "strings" "testing" "time")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" var src =rand.NewSource(time.Now().UnixNano())const(//6 bits to represent a letter index letterIdBits = 6 //All 1-bits as many as letterIdBits letterIdMask = 1<<letterIdBits - 1letterIdMax= 63 /letterIdBits
)

func randStr(n
int) string{
sb :
=strings.Builder{}
sb.Grow(n)
//A rand.Int63() generates 63 random bits, enough for letterIdMax letters! for i, cache, remain := n-1, src.Int63(), letterIdMax; i >= 0; {if remain == 0{
cache, remain
=src.Int63(), letterIdMax
}
if idx := int(cache & letterIdMask); idx <len(letters) {
sb.WriteByte(letters[idx])
i
--}
cache
>>=letterIdBits
remain
--}returnsb.String()
}

func TestApproach7(t
*testing.T) {
fmt.Println(randStr(
10))
}

func BenchmarkApproach7(b
*testing.B) {for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

在构造出builder之后,我们立刻调用了
Builder.Grow()
方法,使得它分配一个足够大的底层slice,避免在后续操作中再进行分配

8. “Mimicing” strings.Builder with package unsafe

模仿string.Builder使用unsafe包

string.Builder跟我们第六节地解法一样,都是用
[]byte
来构建字符串。切换到strings.Builder可能有一些太重了,我们使用strings.Builder只是想避免拷贝slice。

string.Builder使用
unsafe
包来避免最终的拷贝

//String returns the accumulated string.
func (b *Builder) String() string{return *(*string)(unsafe.Pointer(&b.buf))
}

我们也可以自己完成这个流程。所以思路是我们通过
unsafe
包来返回一个字符串,来避免拷贝

package approach8

import (
"fmt" "math/rand" "testing" "time" "unsafe")const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" var src =rand.NewSource(time.Now().UnixNano())const(//6 bits to represent a letter index letterIdBits = 6 //All 1-bits as many as letterIdBits letterIdMask = 1<<letterIdBits - 1letterIdMax= 63 /letterIdBits
)

func randStr(n
int) string{
b :
= make([]byte, n)//A rand.Int63() generates 63 random bits, enough for letterIdMax letters! for i, cache, remain := n-1, src.Int63(), letterIdMax; i >= 0; {if remain == 0{
cache, remain
=src.Int63(), letterIdMax
}
if idx := int(cache & letterIdMask); idx <len(letters) {
b[i]
=letters[idx]
i
--}
cache
>>=letterIdBits
remain
--}return *(*string)(unsafe.Pointer(&b))
}

func TestApproach8(t
*testing.T) {
fmt.Println(randStr(
10))
}

func BenchmarkApproach8(b
*testing.B) {for i := 0; i < b.N; i++{
_
= randStr(10)
}
}

Benchmark

go test ./... -bench=. -benchmem

原作者测试的数据

(译者注:第三列代表操作一次需要多少纳秒)

BenchmarkRunes-4                     2000000    723 ns/op   96 B/op   2 allocs/op
BenchmarkBytes
-4 3000000 550 ns/op 32 B/op 2 allocs/op
BenchmarkBytesRmndr
-4 3000000 438 ns/op 32 B/op 2 allocs/op
BenchmarkBytesMask
-4 3000000 534 ns/op 32 B/op 2 allocs/op
BenchmarkBytesMaskImpr
-4 10000000 176 ns/op 32 B/op 2 allocs/op
BenchmarkBytesMaskImprSrc
-4 10000000 139 ns/op 32 B/op 2 allocs/op
BenchmarkBytesMaskImprSrcSB
-4 10000000 134 ns/op 16 B/op 1 allocs/op
BenchmarkBytesMaskImprSrcUnsafe
-4 10000000 115 ns/op 16 B/op 1 allocs/op

译者测试的数据

BenchmarkApproach1-12            3849038               299.5 ns/op            64 B/op          2 allocs/op
BenchmarkApproach2
-12 5545350 216.4 ns/op 32 B/op 2 allocs/op
BenchmarkApproach3
-12 7003654 169.7 ns/op 32 B/op 2 allocs/op
BenchmarkApproach4
-12 7164259 168.7 ns/op 32 B/op 2 allocs/op
BenchmarkApproach5
-12 13205474 89.06 ns/op 32 B/op 2 allocs/op
BenchmarkApproach6
-12 13665636 84.41 ns/op 32 B/op 2 allocs/op
BenchmarkApproach7
-12 17213431 70.37 ns/op 16 B/op 1 allocs/op
BenchmarkApproach8
-12 19756956 61.41 ns/op 16 B/op 1 allocs/op

现在跑出来的数据,相原作者时候,已经有了一些变化,不过并不妨碍我们看出来各个方法的趋势:

  • 仅仅只是把rune切换到byte,就获得了性能的大幅度提升(大于百分之20)
  • 使用
    rand.Int63()
    代替
    rand.Intn()
    也获得大幅度提升(大于百分之20)
  • 使用Masking并没有提升性能,相反在原作者哪里,反而性能下降了
  • 不过使用了一次
    rand.Int63()
    返回的全部字符后,性能提升了3倍
  • 使用
    rand.Source
    替代
    rand.Rand
    ,性能提升了21%
  • 使用
    strings.Builder
    ,我们在速度上提升了3.5%,并且把原本2次的内存分配,降低到了一次!
  • 使用
    unsafe
    包来代替
    strings.Builder
    ,性能提升了14%

将第八个方案和第一个方案比较,第八个方案比第一个方案快6.3倍,仅仅使用六分之一的内存,分配次数也只有原来的一半。

点击关注,第一时间了解华为云新鲜技术~

在 23 年的 CSS 新特性中,有一个非常重要的功能更新 --
相对颜色

简单而言,相对颜色的功能,让我们在 CSS 中,对颜色有了更为强大的掌控能力。

其核心功能就是,让我们
能够基于一个现有颜色 A,通过一定的转换规则,快速生成我们想要的颜色 B

其功能能够涵盖:

完整的教程,你可以看这里 --
Chrome for Developers- CSS 相对颜色语法

当然,今天我们不会一个一个去过这些功能,更多的时候,我们只需要知道我们能够实现这些功能。

本文,我们将从实际实用角度出发,基于实际的案例,看看 CSS 相对颜色,能够如何解决我们的一些实际问题。

快速语法入门

首先,我们通过一张图,一个案例,快速入门 CSS 相对颜色语法:

相对颜色语法的目标是允许从另一种颜色派生颜色。

上图显示了将原始颜色 green 转换为新颜色的
颜色空间
后,该颜色会转换为以 r、g、b 和 alpha 变量表示的各个数字,这些数字随后会直接用作新的 rgb() 颜色的值。

举个例子:

<p> CSS Relative Color </p>
p {
    color: rgb(255, 0, 0);
}

实现一个 color 为红色(rgb 值为
rgb(255, 0, 0)
)的字体:

基于上面的相对颜色语法,我如何通过一个红色生成绿色文字呢?示意如下:

p {
    --color: rgb(255, 0, 0);
    color: rgb(from var(--color) calc(r - 255) calc(g + 255) b);  /* result = rgb(0, 255, 0) */
}

效果如下,我们就得到绿色字体:

解释一下:

  1. 原本的红色颜色,我们把它设置为 CSS 变量
    --color: rgb(255, 0, 0)
  2. 想通过红色得到绿色,对于红色的 rgb 值
    rgb(255, 0, 0)
    而言,需要转换成
    rgb(0, 255, 0)
  3. 使用 CSS 相对颜色语法,就是
    rgb(from var(--color) calc(r - 255) calc(g + 255) b)

通过这个 DEMO,我们把几个核心基础语法点学习一下:

  1. from 关键字

from
关键字,它是相对颜色的核心。它表示
会将 from 关键字后的颜色定义转换为相对颜色!在 from 关键字后面,CSS 会期待一种颜色,即能够启发生成另一种颜色

  1. from 关键字
    后的颜色表示,支持不同颜色表示或者是 CSS 变量

第二个关键点,from 后面通常会接一个颜色值,这个颜色值可以是任意颜色表示法,或者是一个 CSS 变量,下面的写法都是合法的:

p {
    color: rgba(from #ff0000) r g b);
    color: rgb(from rgb(255, 0, 0) r g b);
    color: rgb(from hsl(0deg, 100%, 50%) r g b);
    color: rgb(from var(--hotpink) r g b);
}
  1. 对转换后的变量使用 calc() 或其他 CSS 函数

另外一个非常重要的基础概念就是,我们可以对
(from color r g b)
后的转换变量
r g b
使用 calc() 或其他 CSS 函数。

就是我们上面的例子:

p {
    --color: rgb(255, 0, 0);
    color: rgb(from var(--color) calc(r - 255) calc(g + 255) b);  /* result = rgb(0, 255, 0) */
}
  1. 相对颜色语法支持,各种颜色表示函数:

相对颜色的基础的使用规则就是这样,它不仅支持
rgb
颜色表示法,它支持所有的颜色表示法:

使用 CSS 相对颜色,实现统一按钮点击背景切换

通常页面上的按钮,都会有 hover/active 的颜色变化,以增强与用户的交互。

像是这样:

最常见的写法,就是我们需要在 Normal 状态、Hover 状态、Active 状态下写 3 种颜色:

p {
    color: #ffcc00;
    transition: .3s all;
}
/* Hover 伪类下为 B 颜色 */
p:hover {
    color: #ffd21f;
}
/** Active 伪类下为 C 颜色 **/
p:active {
    color: #ab8a05;
}

在之前,我们介绍过一种利用滤镜
filter: contrast()
或者
filter: brightness()
的统一解决方案,无需写多个颜色值,可以根据 Normal 状态下的色值,通过滤镜统一实现更亮、或者更暗的伪类颜色。

在今天,我们也可以利用 CSS 相对颜色来做这个事情:

div {
    --bg: #fc0;
    background: var(--bg);
    transition: .3s all;
}

div:hover {
    background: hsl(from var(--bg) h s calc(l * 1.2));
}
div:active {
    background: hsl(from var(--bg) h s calc(l * 0.8));
}

我们通过
hsl
色相、饱和度、亮度颜色表示法表示颜色。实现:


  1. :hover
    状态下,根据背景色,将背景亮度 l 调整为原背景色的 1.2 倍

  2. :avtive
    状态下,根据背景色,将背景亮度 l 调整为原背景色的 0.8 倍

在实际业务中,这是一个非常有用的用法。

完整的 DEMO,你可以戳这里:
CodePen Demo -- https://codepen.io/Chokcoco/pen/KKEdOeb

使用 CSS 相对颜色,实现文字颜色自适应背景

相对颜色,还有一个非常有意思的场景 --
让文字颜色能够自适应背景颜色
进行展示。

有这么一种场景,有的时候,无法确定文案的背景颜色的最终表现值(因为背景颜色的值可能是后台配置,通过接口传给前端),但是,我们又需要能够让文字在任何背景颜色下都正常展现(譬如当底色为黑色时文字应该是白色,当背景为白色时,文字应该为黑色)。

像是这样:

在不确定背景颜色的情况下,无论什么情况,文字颜色都能够适配背景的颜色。

在之前,纯 CSS 没有特别好的方案,可以利用
mix-blend-mode: difference
进行一定程度的适配:

div {
    // 不确定的背景色
}
p {
    color: #fff;
    mix-blend-mode: difference;
}

实操过这个方案的同学都会知道,在一定情况下,前景文字颜色还是会有一点瑕疵。并且,
混合模式这个方案最大的问题是会影响清晰度

有了 CSS 相对颜色后,我们有了更多的纯 CSS 方案。

利用 CSS 相对颜色,反转颜色

我们可以利用相对颜色的能力,基于背景色颜色进行反转,赋值给 color。

一种方法是将颜色转换为 RGB,然后从 1 中减去每个通道的值。

代码非常简单:

p {
    /** 任意背景色 **/
    --bg: #ffcc00;
    background: var(--bg);

    color: rgb(from var(--bg) calc(1 - r) calc(1 - g) calc(1 - b));  /** 基于背景反转颜色 **/
}

用 1 去减,而不是用 255 去,是因为此刻,会将
rgb()
表示法中的
0~255
映射到
0~1

效果如下:

配个动图,我们利用背景色的反色当 Color 颜色,适配所有背景情况:

完整的 DEMO 和代码,你可以戳这里:
CodePen Demo -- CSS Relatvie Color Adapt BG

当然,这个方案还有两个问题:

  1. 如果颜色恰好是在
    #808080
    灰色附近,它的反色,其实还是它自己!会导致在灰色背景下,前景文字不可见;
  2. 绝大部分情况虽然可以正常展示,但是并不是非常美观好看

为了解决这两个问题,CSS 颜色规范在
CSS Color Module Level 6
又推出了一个新的规范 --
color-contrast()

利用
color-contrast()
,选择高对比度颜色

color-contrast() 函数标记接收一个
color
值,并将其与其他的
color
值比较,从列表中选择最高对比度的颜色。

利用这个 CSS 颜色函数,可以完美的解决上述的问题。

我们只需要提供
#fff
白色和
#000
黑色两种可选颜色,将这两种颜色和提供的背景色进行比较,系统会自动选取对比度更高的颜色。

改造一下,上面的代码,它就变成了:

p {
    /** 任意背景色 **/
    --bg: #ffcc00;
    background: var(--bg);

    color: color-contrast(var(--bg) vs #fff, #000);  /** 基于背景色,自动选择对比度更高的颜色 **/
}

这样,上面的 DEMO 最终效果就变成了:

完整的 DEMO 和代码,你可以戳这里:
CodePen Demo -- CSS Relatvie Color Adapt BG

此方案的优势在于:

  1. 可以限定前景 color 颜色为固定的几个色值,以保证 UI 层面的统一及美观
  2. 满足任何情况下的背景色

当然,唯一限制这个方案的最大问题在于,当前,
color-contrast
还只是一个实验室功能,未大规模被兼容。

总结一下

到今天,我们可以利用 CSS 提供的各类颜色函数,对颜色有了更为强大的掌控力。

很多交互效果,不借助 JavaScript 的运算,也能计算出我们想要的最终颜色值。本文简单的借助:

  1. 使用 CSS 相对颜色,实现统一按钮点击背景切换
  2. 使用 CSS 相对颜色,实现文字颜色自适应背景

两个案例,介绍了 CSS 相对颜色的功能。但它其实还有更为广阔的应用场景,完整的教程,你可以看这里 --
Chrome for Developers- CSS 相对颜色语法

最后


好了,本文到此结束,希望本文对你有所帮助

热点随笔:

·
马某 说c# 不开源,他是蠢还是坏?
(
张善友
)
·
园子开店记-周边第一款:鼠标垫设计图出炉
(
博客园团队
)
·
码农的转型之路-偶遇大佬情况或有变
(
木子清
)
·
C#/.NET/.NET Core优秀项目和框架2023年12月简报
(
追逐时光者
)
·
动荡的2023年
(
咖啡机(K.F.J)
)
·
5 款开源热搜项目「GitHub 热点速览」
(
削微寒
)
·
微软真是活菩萨,面向初学者的机器学习、数据科学、AI、LLM课程统统免费
(
机器学习算法与Python
)
·
DDD落地实践-架构师眼中的餐厅 | 京东云技术团队
(
京东云开发者
)
·
微软成为PostgreSQL主要贡献者
(
桦仔
)
·
一个WPF版的Layui前端UI库
(
追逐时光者
)
·
零基础电气专业毕业生,花费9.9元自学前端,成都月薪6.5K
(
欧阳码农
)
·
C#创历史成为2023年度编程语言!!!
(
Dotnet9个人博客
)

热点新闻:

·
一秒100张实时生成二次元老婆照!登顶GitHub热榜、已开源
·
张朝阳跨年演讲推导广义相对论:百年前的方程决定了GPS精确到米量级
·
60年首次!AI发现首批新抗生素,MIT重磅研究登Nature!人类有望对抗超级细菌
·
当蚂蚁不再姓“马”
·
雷军百万美金重奖技术团队,小米软硬两手抓,对话高管和获奖工程师
·
谷歌家务机器人单挑斯坦福炒虾机器人!端茶倒水逗猫,连甩三连弹开打
·
小米汽车的9100吨并不先进,把自己吹成“行业唯一”,纯粹在误导消费者
·
这只戏精小绿鸟,怎么让全球5亿人战战兢兢
·
百度拟将量子实验室捐赠予北京量子院
·
兵马俑跳科目三,贝佐斯跳宅舞...阿里通义千问都安排上了!
·
华为年营收重回 7000 亿
·
开发速率飙升20倍!GPT Pilot明星项目登Github热榜,从0开始构建AI

1、准备材料

开发板(
正点原子stm32f407探索者开发板V2.4

ST-LINK/V2驱动
STM32CubeMX软件(
Version 6.10.0

keil µVision5 IDE(
MDK-Arm

CH340G Windows系统驱动程序(
CH341SER.EXE

XCOM V2.6串口助手
逻辑分析仪
nanoDLA

2、实验目标

使用STM32CubeMX软件配置STM32F407开发板
USART1与PC进行异步通信(阻塞传输方式、中断传输方式)
,具体为 使用WK_UP按键触发串口输出,每按下一次WK_UP按键就以中断方式发送一次数据,并在串口传输完成中断回调函数中输出提示信息和翻转RED_LED灯的状态,同时使用串口中断接收回调函数完成对用户发来的命令解析,发送命令“#1;”则点亮GREEN_LED,发送命令“#0;”则熄灭GREEN_LED。

3、实验流程

3.0、前提知识

USART为通用同步异步收发器,是一种串行通信接口,类似的通信协议还有USB、RS232和RS485等,他们之间电平不同因此不可以直接通信,但是可以通过转换芯片进行逻辑电平的相互转换,从而实现在不同的串行通信方式下的信息传输

对于STM32F4系列来说,USART的高电平1表示的电压范围为2.0V
3.3V(通常VDD电源电压的大约70-100%),低电平0的电压范围为0V
0.3V;USART通信中一般需要设置波特率、数据字长、校验位和停止位四个参数,如下图所示位串行数据发送时序图

波特率:由于本实验的串口工作在异步通信模式,因此需要规定一个特定的传输速率,这样收发双方都以该速率解析发送的内容,才能不出错的进行通信,常见波特率9600/115200等,当然也可自定义波特率

数据字长:可选8/9位,即一帧数据中传输的数据位数,由于一字节为8位,因此该参数默认为8位

校验位:可选无/奇/偶校验

停止位:可选1/2个停止位,一般选择1个停止位

设置波特率为115200,8位字长,无校验位,1个停止位,利用单片机串口发送“Reset\r\n”信息,然后利用逻辑分析仪对TX引脚电平进行捕获,如下图所示为TX引脚捕获电平波形图

STM32F407ZGT6一共有6组串口,包括4组通用同步/异步收发器USART1、2、3、6和2组通用异步收发器UART4、5,通用异步收发器可以工作在异步通信、单线半双工、多处理器通信、红外和局域互连网络(LIN)等模式,而通用同步/异步收发器除可以工作在上述模式外还具有同步通信和智能卡等工作模式,
本文只介绍这6组串口的异步通信模式(最常用的模式),其他模式均不涉及
,如下图示为USART1可选工作模式列表

单片机的串口并不能直接和电脑的USB端口通信,因而需要在单片机和电脑之间利用串口芯片搭建沟通的桥梁,常用的串口芯片有CH34XX和CP210X,对于
串口芯片一般需要安装驱动程序
,请自行查看开发板串口所示用的串口芯片,然后下载对应驱动程序,一般来说能够实现电脑和单片机正常串口通信需要满足“电脑USB接口 ⇔ 开发板USB接口 ⇔ 串口芯片 ⇔ 单片机串口RX/TX引脚”的物理连接
(注释1)
,当其他的一切均正常使用USB线连接电脑与开发板,在Windows的设备管理器页面,端口栏目下会出现对应串口芯片识别成功的端口号,如下图所示

串口通信中数据传输一般可以分为阻塞式数据传输和非阻塞式数据传输两种
,而阻塞模式也即轮询模式,在此模式下,串口发送或者接收数据都会产生阻塞,单片机只能一直等待接收/发送完成或者达到设定的超时时间;非阻塞模式是使用中断或者DMA的方式来传输数据,顾名思义,不会产生阻塞现象,发送/接收数据的同时单片机还可以处理其他任务。本文不涉及DMA,因此非阻塞模式仅仅介绍使用中断的传输方式

3.1、CubeMX相关配置

请先阅读“
STM32CubeMX教程1 工程建立
”实验3.4.1小节配置RCC和SYS

3.1.1、时钟树配置

系统时钟树配置与上一实验一致,均设置为STM32F407总线能达到的最高时钟频率,具体如下图所示

3.1.2、外设参数配置

在Pinout & Configuration页面左边功能分类栏目Connectivity中
单击其中USART1

页面中间USART1 Mode and Configuration中将串口模式
设置为异步通信工作模式
,无硬件流控制

然后在Configuration页面中设置USART1的相关参数,主要有波特率、字长、奇偶校验位、停止位、数据方向和过采样率6个参数,一般无需更改,但要确保接收端设置与发送端一致,其他5个串口在异步通信模式下与USART1一致,唯一区别在于RX/TX引脚不同,具体参数解释可以阅读本实验“3.0、前提知识”小节

具体设置如下图所示

3.1.3、外设中断配置

在页面左边功能分类栏目中单击System Core/NVIC,
勾选USART1全局中断,并设置合适的中断优先级

如果在串口中断中会使用到HAL库的延时函数,注意不要与滴答定时器优先级一致(注释2)

具体设置如下图所示

3.2、生成代码

请先阅读“
STM32CubeMX教程1 工程建立
”实验3.4.3小节配置Project Manager

单击页面右上角GENERATE CODE生成工程

3.2.1、外设初始化函数调用流程

在工程代码主函数main()中调用MX_USART1_UART_Init()函数对串口1相关参数进行了配置

在该MX_USART1_UART_Init()函数中调用了HAL_UART_Init()函数对串口1进行了初始化

在该初始化HAL_UART_Init()函数中又调用了HAL_UART_MspInit()函数对串口1时钟,中断,引脚复用做了相关配置

如下图所示为具体的USART1初始化调用流程

此时我们就可以让串口工作在阻塞模式下,通过如下所示的两个函数阻塞式的发送或接收数据

HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout)

HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)

3.2.2、外设中断函数调用流程

勾选USART1全局中断后,在工程文件stm32f4xx_it.c中生成了USART1全局中断服务函数USART1_IRQHandler()

该函数调用了HAL库的串口统一中断处理函数HAL_UART_IRQHandler(),在该函数中通过一系列的判断,最终根据不同的串口事件调用不同的回调函数

当串口以中断方式发送完成数据时会调用串口完成中断传输回调函数HAL_UART_TxCpltCallback()

当串口以中断方式接收完成数据时会调用串口中断接收完毕回调函数HAL_UART_RxCpltCallback()

如下图所示为具体的USART1串口Tx传输完成中断调用流程

同理,感兴趣的可以自己找一找中断接收完毕回调函数HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)的调用流程

**用户只需记住经过上述3.1.2和3.1.3所做的配置生成的代码,然后重新实现HAL_UART_TxCpltCallback(UART_HandleTypeDef
huart)、HAL_UART_RxCpltCallback(UART_HandleTypeDef huart)两个函数,第一个函数为串口中断发送完毕回调函数,每次使用HAL_UART_Transmit_IT传输数据传输完之后就会进入该函数;第二个为串口中断接收完毕回调函数,使用HAL_UART_Receive_IT接收数据时,一旦数据接收完毕之后就会进入该函数

3.2.3、添加其他必要代码

需要提到一点是,
使用中断的方式接收指定长度数据时,一旦接收一次完毕,第二次不会自动启动接收
,此时需要用户手动调用以中断方式接收串口数据的函数HAL_UART_Receive_IT。而一个串口往往有三种状态,要么在发送数据,要么在接收数据,要么在偷懒处于空闲状态,因此
在空闲状态时重新启动中断串口接收是比较正确的选择,这里就需要我们自己设置一个串口的空闲中断回调函数on_UART_IDLE,当接受完一次数据后,将空闲中断使能,在空闲的时候进入空闲中断回调函数,处理刚刚接收到的数据并重新启动串口中断接收

接下来我们来实现串口的空闲中断回调函数,将其放在串口1的中断服务函数中,这样串口1的任何中断都会调用该函数,然后在usart.c中实现该函数,在该函数中首先判断是否是空闲中断,如果不判断则任何关于串口1的中断都会执行空闲中断回调函数函数体内容,然后清除空闲中断标志及禁用空闲中断,保证空闲中断回调函数只在串口接收中断完成后才能被触发,接着对串口接收到的数据进行处理,具体处理函数为CMD_PROCESS函数,最后重新启动串口中断接收,具体函数代码如下图所示

串口完成中断传输回调函数和中断接收完毕回调函数重新实现在usart.c中,每次接收完数据都会进入中断接收完毕回调函数,在该回调函数中启动了空闲中断,此时才可以执行空闲中断函数体内的代码,也就是处理命令、重新启动串口中断接收,
值得提醒的是在串口完成中断传输回调函数中使用的串口输出是阻塞的方式输出信息的,不可以使用中断的方式输出提示信息,否则将无限套娃
,具体代码如下图所示

源代码如下

/*串口结束传输中断*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    printf("Into HAL_UART_TxCpltCallback Function\r\n");
    HAL_GPIO_TogglePin(RED_LED_GPIO_Port,RED_LED_Pin);
}
 
/*串口接收完成中断*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        //接收到固定长度数据后使能UART_IT_IDLE中断,在UART_IT_IDLE中断里再次接收
        //接收完成标志
        rxCompleted=SET;	
        //复制接收到的数据到缓冲区
        for(uint16_t i=0;i<RX_CMD_LEN;i++)
            proBuffer[i] = rxBuffer[i];
 
        //接收到数据后才开启IDLE中断
        __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); 
    }
}
 
/*串口空闲回调函数*/
void on_UART_IDLE(UART_HandleTypeDef *huart)
{
    //判断IDLE中断是否被开启
    if(__HAL_UART_GET_IT_SOURCE(huart, UART_IT_IDLE) == RESET) 
        return;
 
    //清除IDLE标志
    __HAL_UART_CLEAR_IDLEFLAG(huart); 	
    //禁止IDLE中断
    __HAL_UART_DISABLE_IT(huart, UART_IT_IDLE); 	
    //接收完成
    if(rxCompleted)	
    {
        //上传接收到的指令
        printf("Receive CMD is %s\r\n",proBuffer);
        //处理指令
        CMD_PROCESS();
        //再次接收
        rxCompleted = RESET;
        //再次启动串口接收
        HAL_UART_Receive_IT(huart, rxBuffer, RX_CMD_LEN); 
    }
}
 
/*接收命令处理函数*/
void CMD_PROCESS(void)
{
    //非法的命令格式
    if(proBuffer[0] != '#' && proBuffer[2] != ';')
    {
        printf("Unlawful Orders\r\n");
        return;
    }
    //解析命令
    uint8_t	CMD = proBuffer[1]-0x30;
    //控制GREEN_LED
    if(CMD == 1)
    {
        HAL_GPIO_WritePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin, GPIO_PIN_RESET);
        printf("GREEN_LED ON\r\n");
    }
    else if(CMD == 0) 
    {
        HAL_GPIO_WritePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin, GPIO_PIN_SET);
        printf("GREEN_LED OFF\r\n");
    }
}

最后我们在主函数中以中断方式启动串口接收,然后编写WK_UP按键响应函数,每按下一次按键以中断方式发送一次数据,具体的代码如下图所示

上述代码中的一些变量均定义/声明在了usart.c/usart.h中,具体源代码如下

/*usart.c中定义的变量*/
uint8_t	rxBuffer[3]="#0;";	//数据接收缓冲区
uint8_t proBuffer[3]="#1;";	//数据处理缓冲区
uint8_t	rxCompleted=RESET;	//数据接收完成标志
 
/*usart.h中声明的变量*/
#define RX_CMD_LEN 3              //数据接收长度
extern uint8_t rxBuffer[];        //外部声明
void on_UART_IDLE(UART_HandleTypeDef *huart);     //函数声明
void CMD_PROCESS(void);                           //函数声明
 
/*main()函数按键WK_UP控制代码*/
if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
{
    HAL_Delay(50);
    if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
    {
        HAL_UART_Transmit_IT(&huart1, (uint8_t *)"Key WK_UP Pressed!\r\n", 20);
        while(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin));
    }
}

4、常用函数

/*串口阻塞接收数据*/
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)
 
/*串口阻塞发送数据*/
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout)
 
/*串口中断接收数据*/
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
 
/*串口中断发送数据*/
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size)
 
/*串口中断接收数据完毕回调函数*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
 
/*串口中断发送数据完毕回调函数*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)

5、烧录验证

5.1、具体步骤

“启动USART1异步通信模式 -> 配置串口相关参数 -> 使能USART1全局中断 -> 在usart.c中重新实现①串口结束传输中断回调函数HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)②串口接收完毕中断回调函数void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) -> 添加串口空闲回调函数on_UART_IDLE -> 实现命令解析函数CMD_PROCESS -> 添加本实验控制代码(具体代码请看上述3.2小节)”

5.2、实验现象

烧录程序,开发板上电,此时按键WK_UP被按下,串口会同时输出信息,输出完毕后进入串口结束传输中断回调函数,输出提示信息并将RED_LED状态翻转,PC发送"#1;"给MCU,串口输出接收到的信息,然后解析命令,打开GREEN_LED,PC发送"#0;"给MCU,串口输出接收到的信息,然后解析命令,熄灭GREEN_LED,按键WK_UP又被按下,串口输出信息,输出完毕后进入串口结束传输中断回调函数,输出提示信息并将RED_LED状态翻转
(注释3)
,如下图所示为串口的详细输出信息

6、串口printf重定向

用户阻塞式的发送一条数据时使用的HAL_UART_Transmit函数需要指定发送数据的字节数,非常的不方便,因此简单使用串口传输数据时有必要将其重定向到我们熟悉的printf函数,以下为具体步骤

首先需要在工程设置页面勾选“Use MicroLIB”,如下图所示

然后在工程main.c文件中加入printf函数所需的头文件“#include <stdio.h> ”,并在主函数上方添加重定向函数,如下图所示,红框中的串口实例可以替换成任何正常的串口实例

源代码如下

#include <stdio.h> 
 
#ifdef __GNUC__
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#endif
PUTCHAR_PROTOTYPE
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}

之后在工程任何文件处均可使用printf函数用作串口函数阻塞输出从而替代HAL_UART_Transmit函数(在其它文件使用记得添加头文件#include <stdio.h>)

7、注释详解

注释1
:如果你觉得自己的一切配置都没有问题,但是串口就是没有任何字符输出,可以用串口模块尝试开发板上其他的串口引脚,因为有时候开发板的某一个串口引脚可能被其他外设使用,物理上造成了冲突,无法用软件解决,比如笔者之前使用的STM32F407G-DISC1开发板其USART1就不能正常使用

注释2
:如果设置串口中断优先级与系统滴答定时器优先级一致,那么在串口中断服务函数中使用HAL库的延时函数HAL_Delay的话,系统滴答定时器不能抢占串口中断,因此会出现程序卡死在HAL_Delay函数的情况

注释3
:注意笔者此实验只是简单介绍每个功能的使用方法,这里的代码其实是有BUG的,如果用户不按照"#1;"/"#0;"的命令格式发送数据,而是只发送1个字符,比如"q",然后再按照"#1;"/"#0;"的命令格式发送数据,那么程序接收到的命令将错乱,导致不能正常解析命令

参考资料

STM32Cube高效开发教程(基础篇)

更多内容请浏览
OSnotes的CSDN博客

本文内容提炼于《
Vue.js设计与实现
》,全书共 501 页,对 Vue.js 的设计原理从 0 到 1,循序渐进的讲解。

篇幅比较长,需要花些时间慢慢阅读,在合适的位置会给出在线示例以供调试。

一、概览

Vue.js 是一款声明式框架,注重结果;早年间流行的 jQuery 是典型的命令式框架,注重过程。命令式的代码需要维护实现目标的整个过程,例如手动完成 DOM 元素的创建、更新、删除等工作。

Vue.js 帮我们封装了过程, 其内部是命令式的实现,而暴露给用户的则是声明式。虽然声明式代码的性能劣于命令式,但是可维护性更强,其更新性能公式如下。

声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

虚拟 DOM,就是为了公式中找出差异的性能消耗而出现的。虽然其更新性能理论上不可能比原生的 JavaScript 直接操作 DOM 更高,但是它能保证应用程序的性能下限而不至于太差。

虽然在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时会多出一个 Diff 的性能消耗,但是它毕竟也是 JavaScript 层面的运算,所以不会产生数量级的差异。

1)架构

Vue.js 3 采用了运行时 + 编译时的架构,运行时是指用户可以直接提供数据对象从而无须编译。而编译时是指用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。

纯运行时没办法分析用户提供的内容,也就无法对内容做进一步优化。纯编译时虽然能直接编译成可执行的 JavaScript 代码(性能可能会更好),但是有损灵活性,即用户提供的内容必须编译后才能用。

2)核心要素

Vue.js 作为一款优秀的框架,其核心要素包括:

  1. 开发体验,例如提供友好的警告信息,输出更友好的信息等。
  2. 代码体积,例如支持 Tree-Shaking,构建生产环境时移除开发代码等。
  3. 特性开关,例如为框架添加新特性,支持遗留 API 等。
  4. 错误处理,例如为用户提供统一的错误处理接口。
  5. 支持TypeScript,为 JavaScript 提供类型检查等功能。

3)声明式地描述

Vue.js 3 是一个声明式的 UI 框架,即用户在使用 Vue.js 3 开发页面时可以声明式地描述 UI。

除了使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述(其实就是虚拟 DOM),而使用 JavaScript 对象描述 UI 会更加灵活。虚拟 DOM 其实就是用 JavaScript 对象来描述真实的 DOM 结构,如下所示。

const vnode ={
tag:
"div",
props: {
onClick: ()
=> alert("hello")
},
children:
"click me"};

4)渲染器

渲染器(renderer)的作用就是把虚拟 DOM 渲染为真实 DOM,平时编写的 Vue.js 组件都是依赖渲染器来工作的,下面是一个简单的渲染器实现。

functionrenderer(vnode, container) {//使用 vnode.tag 作为标签名称创建 DOM 元素
  const el =document.createElement(vnode.tag);//遍历 vnode.props,将属性、事件添加到 DOM 元素
  for (const key invnode.props) {if (/^on/.test(key)) {//如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(
2).toLowerCase(), //事件名称 onClick ---> click vnode.props[key] //事件处理函数 );
}
}
//处理 children if (typeof vnode.children === "string") {//如果 children 是字符串,说明它是元素的文本子节点 el.appendChild(document.createTextNode(vnode.children));
}
else if(Array.isArray(vnode.children)) {//递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点 vnode.children.forEach((child) =>renderer(child, el));
}
//将元素添加到挂载点下 container.appendChild(el);
}

现在所做的还仅仅是创建节点,渲染器的精髓是在更新节点的阶段,涉及 Diff  算法。

const vnode ={
tag:
"div",
props: {
onClick: ()
=> alert("hello")
},
children:
"click again" //从 click me 改成 click again };

对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 API 操作 DOM 来完成渲染工作。

5)组件的本质

虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。

组件的本质就是一组 DOM 元素的封装,可以定义一个函数来代表组件,其返回值就是组件要渲染的内容。

const MyComponent = function() {return{
tag:
"div",
props: {
onClick: ()
=> alert("hello")
},
children:
"click me"};
};

可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,就可以用虚拟 DOM 来描述组件了,用 tag 属性来存储组件函数:

const vnode ={
tag: MyComponent
};

为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数。

functionrenderer(vnode, container) {if (typeof vnode.tag === "string") {//说明 vnode 描述的是标签元素
mountElement(vnode, container);
}
else if (typeof vnode.tag === "function") {//说明 vnode 描述的是组件 mountComponent(vnode, container);
}
}

mountElement 函数与上文中 renderer 函数的内容一致。如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。

functionmountComponent(vnode, container) {//调用组件函数,获取组件要渲染的内容(虚拟 DOM)
  const subtree =vnode.tag();//递归地调用 renderer 渲染 subtree
renderer(subtree, container);
}

6)模板

无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。提到模板的工作原理,那就需要讲解一下 Vue.js 中的另外一个重要组成部分:编译器。

编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同。编译器的作用其实就是将模板编译为渲染函数。

<div@click="handler">click me</div>

对于编译器来说,模板就是一个普通的字符串,它会分析上述字符串并生成一个功能与之相同的渲染函数:

render() {return h('div', { onClick: handler }, 'click me')
}

以我们熟悉的 .vue 文件为例,一个 .vue 文件就是一个组件,如下所示:

<template>
  <div@click="handler">click me</div>
</template>
<script>exportdefault{
data() {
/*...*/},
methods: {
handler: ()
=>{/*...*/}
}
</script>

其中 <template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上,所以最终在浏览器里运行的代码就是:

export default{
data() {
/*...*/},
methods: {
handler: ()
=> {/*...*/}
}
render() {
return h('div', { onClick: handler }, 'click me')
}
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。

7)有机整体

组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的, 因此 Vue.js 的各个模块之间是互相关联、互相制约的,共同构成一个有机整体。

我们在学习 Vue.js 原理的时候,应该把各个模块结合到一起去看,才能明白到底是怎么回事。

二、响应系统

响应系统也是 Vue.js 的重要组成部分,Vue.js 3 采用 ES6 的 Proxy 实现响应式数据。

1)副作用函数

副作用函数指执行它会直接或间接影响其他函数的执行。副作用很容易产生,例如一个函数修改了全局变量。

//全局变量
let val = 1
functioneffect() {
val
= 2 //修改全局变量,产生副作用 }

2)响应式数据的实现

要让 obj 对象变成响应式数据,可以通过拦截它的读取和设置操作来实现。

const obj = { text: 'hello world' }

例如当读取字段 obj.text 时,把副作用函数 effect 存储到一个桶里。在设置 obj.text 时,再把副作用函数 effect 从桶里取出并执行即可。

在 ES6 之前,只能通过 Object.defineProperty 函数实现(Vue.js 2 的实现方式)。在 ES6+ 中,可以使用代理对象 Proxy 来实现(Vue.js 3 的实现方式)。根据如上思路,采用 Proxy 来实现:

//存储副作用函数的桶
const bucket = newSet();//原始数据
const data = { text: "hello world" }; //对原始数据的代理
const obj = newProxy(data, {//拦截读取操作
get(target, key) {//将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect);//返回属性值
    returntarget[key];
},
//拦截设置操作 set(target, key, newVal) {//设置属性值 target[key] =newVal;//把副作用函数从桶里取出并执行 bucket.forEach((fn) =>fn());//返回 true 代表设置操作成功 return true;
}
});

首先,创建了一个用于存储副作用函数的桶 bucket,它是 Set 类型。接着定义原始数据 data,obj 是原始数据的代理对象,我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。

当读取属性时将副作用函数 effect 添加到桶里,即 bucket.add(effect),然后返回属性值。当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行,这样我们就实现了响应式数据。

可以使用下面的代码来测试一下,或直接查看
在线示例

//副作用函数
functioneffect() {
document.body.innerText
=obj.text;
}
//执行副作用函数,触发读取 effect();//1 秒后修改响应式数据 setTimeout(() =>{
obj.text
= "hello vue3";
},
1000);

但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。因此我们要想办法去掉这种硬编码的机制。

3)完善的响应式系统

首先需要提供一个注册副作用的函数,来解决硬编码的问题。

//用一个全局变量存储被注册的副作用函数
let activeEffect;//effect 函数用于注册副作用函数
functioneffect(fn) {//当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect =fn;//执行副作用函数
fn();
}

可以按照如下所示的方式使用 effect 函数:

effect(//一个匿名的副作用函数
  () =>{
document.body.innerText
=obj.text;
}
);

当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn, 这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数:

const obj = newProxy(data, {
get(target, key) {
//将 activeEffect 中存储的副作用函数收集到“桶”中 if (activeEffect) { //新增 bucket.add(activeEffect); //新增 } //新增 returntarget[key];
},
set(target, key, newVal) {
target[key]
=newVal;
bucket.forEach((fn)
=>fn());return true;
}
});

但如果我们再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:

effect(//匿名副作用函数
  () =>{
console.log(
"effect run"); //会打印 2 次 document.body.innerText =obj.text;
}
);
setTimeout(()
=>{//副作用函数中并没有读取 notExist 属性的值 obj.notExist = "hello vue3";
},
1000);

在匿名副作用函数内并没有读取 obj.notExist 属性的值,所以理论上,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数的重新执行。

但如果我们执行上述这段代码就会发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。注意,在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的“桶”。

导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里。

将属性与副作用函数之间建立一种树型数据结构,联系建立起来之后, 就可以解决前文提到的问题。

拿上面的例子来说,如果我们设置了 obj.text2 的值,就只会导致 effectFn2 函数重新执行,并不会导致 effectFn1 函数重新执行。

target
└── text1
└── effectFn1
└── text2
└── effectFn2

接下来我们尝试用代码来实现这个新的“桶”。首先,需要使用 WeakMap 代替 Set 作为桶的数据结构:

//存储副作用函数的桶
const bucket = newWeakMap()

然后修改 get/set 拦截器代码:

const obj = newProxy(data, {//拦截读取操作
get(target, key) {//没有 activeEffect,直接 return
    if (!activeEffect) returntarget[key];//根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
    let depsMap =bucket.get(target);//如果不存在 depsMap,那么新建一个 Map 并与 target 关联
    if (!depsMap) {
bucket.set(target, (depsMap
= newMap()));
}
//再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型, //里面存储着所有与当前 key 相关联的副作用函数:effects let deps =depsMap.get(key);//如果 deps 不存在,同样新建一个 Set 并与 key 关联 if (!deps) {
depsMap.set(key, (deps
= newSet()));
}
//最后将当前激活的副作用函数添加到“桶”里 deps.add(activeEffect);//返回属性值 returntarget[key];
},
//拦截设置操作 set(target, key, newVal) {//设置属性值 target[key] =newVal;//根据 target 从桶中取得 depsMap,它是 key --> effects const depsMap =bucket.get(target);if (!depsMap) return;//根据 key 取得所有副作用函数 effects const effects =depsMap.get(key);//执行副作用函数 effects && effects.forEach((fn) =>fn());
}
});

之所以使用 WeakMap,是因为它对 key 是弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。

如果 target 对象没有任何引用,说明用户侧不再需要它,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。

最后,我们对上文中的代码做一些封装处理。在目前的实现中,当读取属性值时,我们直接在 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。

同样, 我们也可以把副作用函数重新执行的逻辑封装到 trigger 函数中,查看
在线示例

const obj = newProxy(data, {//拦截读取操作
get(target, key) {//将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);//返回属性值
    returntarget[key];
},
//拦截设置操作 set(target, key, newVal) {//设置属性值 target[key] =newVal;//把副作用函数从桶里取出并执行 trigger(target, key);
}
});
//在 get 拦截函数内调用 track 函数追踪变化 functiontrack(target, key) {//没有 activeEffect,直接 return if (!activeEffect) return;
let depsMap
=bucket.get(target);if (!depsMap) {
bucket.set(target, (depsMap
= newMap()));
}
let deps
=depsMap.get(key);if (!deps) {
depsMap.set(key, (deps
= newSet()));
}
deps.add(activeEffect);
}
//在 set 拦截函数内调用 trigger 函数触发变化 functiontrigger(target, key) {
const depsMap
=bucket.get(target);if (!depsMap) return;
const effects
=depsMap.get(key);
effects
&& effects.forEach((fn) =>fn());
}

4)分支切换

在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok 值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

effect(functioneffectFn() {
document.body.innerText
= obj.ok ? obj.text : 'not'})

当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之间建立的联系如下:

data
└── ok
└── effectFn
└── text
└── effectFn

当字段 obj.ok 的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text 不会被读取,只会触发字段 obj.ok 的读取操作。所以理想情况下副作用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集。

但按照前文的实现,我们还做不到这一点。也就是说,当我们把字段 obj.ok 的值修改为 false,也会导致副作用函数重新执行,查看
在线示例

obj.ok = false;
obj.text
= 'hello vue3';

解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,参考书籍 P52 页。

5)诸多细节

实现响应式数据要比想象中难很多,还需要考虑诸多细节,此节只会列出其中的几点,完整细节可阅读书籍第四章。

首先是 effect 支持嵌套,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的,当组件发生嵌套时,就发生了 effect 嵌套。

effect(functioneffectFn1() {
effect(
function effectFn2() { /*...*/})/*...*/})

其次是避免无限递归循环,在 effect 注册的副作用函数内有一个自增操作 obj.foo++,该操作会引起栈溢出。

effect(() => obj.foo++)

再有就是可调度性,所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

还有就是副作用允许过期,避免竞态问题。下面代码乍一看似乎没什么问题,但仔细思考会发现这段代码会发生竞态问题。

let finalData;
watch(obj, async ()
=>{//发送并等待网络请求 const res = await fetch("/path/to/request");//将请求结果赋值给 data finalData =res;
});

假设我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。

随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。

此时请求 A 和请求 B 都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求 B 先于请求 A 返回结果,就会导致 finalData 中存储的是 A 请求的结果。

6)原始值和非原始

Vue.js 3 对原始值和非原始值各自提供了一种响应式方案,完整内容参考第五和第六章。

原始值指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值,在 JavaScript 中,原始值是按值传递的,而非按引用传递。

这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。另外,JavaScript 中的 Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹,由此引入了 ref 的概念。

为了区分 ref 与普通响应式对象,我们还为“包裹对象”定义了一个值为 true 的属性,即 __v_isRef,用它作为 ref 的标识。

非原始值其实就是对象,响应系统应该拦截对象的一切读取操作,以便当数据变化时能够正确地触发响应,包括访问属性、key in obj、for-in。

浅响应(shallowReactive)只会让对象的第一层属性是响应的,而深响应(reactive)会让所有层成为响应的。

当用户尝试修改只读数据时,会收到一条警告信息。这样就实现了对数据的保护,例如组件接收到的 props 对象应该是一个只读数据。

7)数组

在 JavaScript 中,数组是一个特殊的对象,但对数组的操作与对普通对象的操作存在着不同,包括:

  • 通过索引访问数组元素值:arr[0]。访问数组的长度:arr.length。
  • 把数组作为对象,使用 for...in 循环遍历。
  • 使用 for...of 迭代遍历数组。
  • 数组的原型方法,如 concat/join/every/find 等,以及其他所有不改变原数组的原型方法。

可以看到,对数组的读取操作要比普通对象丰富得多。对数组元素或属性的设置操作有:

  • 通过索引修改数组元素值:arr[1] = 3。
  • 修改数组长度:arr.length = 0。
  • 数组的栈方法:push/pop/shift/unshift。
  • 修改原数组的原型方法:splice/fill/sort 等。

除了通过数组索引修改数组元素值这种基本操作之外,数组本身还有很多会修改原数组的原型方法。

调用这些方法也属于对数组的操作,有些方法的操作语义是“读取”,而有些方法的操作语义是“设置”。因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。

8)Set 和 Map

集合类型包括 Map/Set 以及 WeakMap/WeakSet。使用 Proxy 代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。

Set 类型的原型属性和方法如下:

  • size:返回集合中元素的数量。
  • add(value):向集合中添加给定的值。
  • clear():清空集合。
  • delete(value):从集合中删除给定的值。
  • has(value):判断集合中是否存在给定的值。
  • keys():返回一个迭代器对象。可用于 for...of 循环,迭代器对象产生的值为集合中的元素值。
  • values():对于 Set 集合类型来说,keys() 与 values() 等价。
  • entries():返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值[value, value]。

Map 和 Set 这两个数据类型的操作方法相似。它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加元素,而 Map 类型使用 set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值。

三、渲染器

Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。

渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。渲染器不仅能够渲染真实 DOM 元素,它还是框架跨平台能力的关键。

1)结合响应系统

既然渲染器用来渲染真实 DOM 元素,那么严格来说,下面的函数就是一个合格的渲染器:

functionrenderer(domString, container) {
container.innerHTML
=domString
}

在下面这段代码中,我们首先定义了一个响应式数据 count,它是一 个 ref,然后在副作用函数内调用 renderer 函数执行渲染。

const count = ref(1);
effect(()
=>{
renderer(`
<h1>${count.value}</h1>`, document.getElementById("app")); });
count.value
++;

副作用函数执行完毕后,会与响应式数据建立响应联系。当我们修改 count.value 的值时,副作用函数会重新执行,完成重新渲染。所以上面的代码运行完毕后,最终渲染到页面的内容是 <h1>2</h1>。

我们利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。这个过程与渲染器的具体实现无关,在上面给出的渲染器的实现中,仅仅设置了元素的 innerHTML 内容。

2)基本概念

通常使用英文 renderer 来表达“渲染器”。千万不要把 renderer 和 render 弄混了,前者代表渲染器,而后者是动词,表示“渲染”。

在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素。虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树型结构。所以,我们经常能听到“虚拟节点”这样的词,即 virtual node,简写成 vnode。

渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,用英文 mount 表达。例如 Vue.js 组件中的 mounted 钩子就会在挂载完成时触发。这就意味着,在 mounted 钩子中可以访问真实 DOM 元素。

渲染器通常需要接收一个挂载点作为参数,用来指定具体的挂载位置。这里的“挂载点”其实就是一个 DOM 元素,渲染器会把该 DOM 元素作为容器元素,并把内容渲染到其中,用英文 container 来表达容器。

渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。渲染器不仅可以用来渲染,还可以用来激活已有的 DOM 元素,这个过程通常发生在同构渲染的情况下。

functioncreateRenderer() {functionrender(vnode, container) {}functionhydrate(vnode, container) {}
}

这个例子说明,渲染器的内容非常广泛,而用来把 vnode 渲染为真实 DOM 的 render 函数只是其中一部分。实际上,在 Vue.js 3 中,甚至连创建应用的 createApp 函数也是渲染器的一部分。

在下面的示例中,首先调用 createRenderer 函数创建一个渲染器,接着调用渲染器的 renderer.render 函数执行渲染。

const renderer =createRenderer()//首次渲染
renderer.render(vnode, document.querySelector('#app'))

当多次在同一个 container 上调用 renderer.render 函数进行渲染时,渲染器除了要执行挂载动作外,还要执行更新动作。

试图找到并更新变更点的过程叫作“打补丁”(或更新),英文通常用 patch 来表达。但实际上,挂载动作本身也可以看作一种特殊的打补丁,它的特殊之处在于旧的 vnode 是不存在的。

下面这段代码给出了 render 函数的基本实现。patch 函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。

functioncreateRenderer() {functionrender(vnode, container) {if(vnode) {//新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
patch(container._vnode, vnode, container);
}
else{if(container._vnode) {//旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作 //只需要将 container 内的 DOM 清空即可 container.innerHTML = "";
}
}
//把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode container._vnode =vnode;
}
return{
render
}
}

3)自定义渲染器

通过将渲染器设计为可配置的“通用”渲染器,即可实现渲染到任意目标平台上。

编写一个渲染器,将浏览器特定的 API 抽离,这样就可以使得渲染器的核心不依赖于浏览器。

将 patch 函数也编写在 createRenderer 函数内。在后续的讲解中,如果没有特殊声明,我们编写的函数都定义在 createRenderer 函数内。

functioncreateRenderer() {functionpatch(n1, n2, container) {//如果 n1 不存在,意味着挂载,则调用 mountElement 函数完成挂载
    if (!n1) {
mountElement(n2, container);
}
else{//n1 存在,意味着打补丁,暂时省略 }
}
//...... }

我们调用 mountElement 完成挂载,它的实现如下:

functionmountElement(vnode, container) {//创建DOM元素
  const el =document.createElement(vnode.type);//处理子节点,如果子节点是字符串,代表元素具有文本节点
  if (typeof vnode.children === "string") {//因此只需要设置元素的 textContent 属性即可
    el.textContent =vnode.children;
}
//将元素添加到容器中 container.appendChild(el);
}

首先调用 document.createElement 函数,以 vnode.type 的值作为标签名称创建新的 DOM 元素。接着处理 vnode.children,如果它的值是字符串类型,则代表该元素具有文本子节点,这时只需要设置元素的 textContent 即可。最后调用 appendChild 函数将新创建的 DOM 元素添加到容器元素内。这样, 我们就完成了一个 vnode 的挂载。

我们的目标是设计一个不依赖于浏览器平台的通用渲染器,但很明显,mountElement 函数内调用了大量依赖于浏览器的 API。想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离。

可以将这些操作 DOM 的 API 作为配置项,该配置项可以作为 createRenderer 函数的参数:

//在创建 renderer 时传入配置项
const renderer =createRenderer({//用于创建元素
createElement(tag) {returndocument.createElement(tag);
},
//用于设置元素的文本节点 setElementText(el, text) {
el.textContent
=text;
},
//用于在给定的 parent 下添加指定元素 insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
}
});

我们把用于操作 DOM 的 API 封装为一个对象,并把它传递给 createRenderer 函数。在 mountElement 等函数内就可以通过配置项来取得操作 DOM 的 API 了:

functioncreateRenderer(options) {//通过 options 得到操作 DOM 的 API
  const { createElement, insert, setElementText } =options;//在这个作用域内定义的函数都可以访问那些 API
  functionmountElement(vnode, container) {}functionpatch(n1, n2, container) {}functionrender(vnode, container) {}return{
render
};
}

接着,我们就可以使用从配置项中取得的 API 重新实现 mountElement 函数:

functionmountElement(vnode, container) {//调用 createElement 函数创建元素
  const el =createElement(vnode.type);if (typeof vnode.children === "string") {//调用 setElementText 设置元素的文本节点
setElementText(el, vnode.children);
}
//调用 insert 函数将元素插入到容器内 insert(el, container);
}

重构后的 mountElement 函数在功能上没有任何变化。不同的是,它不再直接依赖于浏览器的特有 API 了。这意味着,只要传入不同的配置项,就能够完成非浏览器环境下的渲染工作。

我们可以实现一个用来打印渲染器操作流程的自定义渲染器,如下所示,查看
在线示例

const renderer =createRenderer({
createElement(tag) {
console.log(`创建元素 ${tag}`);
return{ tag };
},
setElementText(el, text) {
console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`);
el.textContent
=text;
},
insert(el, parent, anchor
= null) {
console.log(`将 ${JSON.stringify(el)} 添加到${JSON.stringify(parent)} 下`);
parent.children
=el;
}
});

在 createElement 内,我们不再调用浏览器的 API,而是仅仅返回一个对象 { tag },并将其作为创建出来的“DOM 元素”。同样,在 setElementText 以及 insert 函数内,我们也没有调用浏览器相关的 API,而是自定义了一些逻辑,并打印信息到控制台。

const vnode ={
type:
"h1",
children:
"hello"};//使用一个对象模拟挂载点 const container = { type: "root"};
renderer.render(vnode, container);

这段代码不仅可以在浏览器中运行,还可以在 Node.js 中运行。

自定义渲染器并不是“黑魔法”,它只是通过抽象的手段,让核心代码不再依赖平台特有的 API,再通过支持个性化配置的能力来实现跨平台。

4)挂载子节点

为了描述元素的子节点,我们需要将 vnode.children 定义为数组:

const vnode ={
type:
"div",
children: [
{
type:
"p",
children:
"hello"}
]
};

为了完成子节点的渲染,我们需要修改 mountElement 函数。

functionmountElement(vnode, container) {
const el
=createElement(vnode.type);if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
}
else if(Array.isArray(vnode.children)) {//如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们 vnode.children.forEach((child) =>{
patch(
null, child, el);
});
}
insert(el, container);
}

在上面这段代码中,我们增加了新的判断分支。使用 Array.isArray 函数判断 vnode.children 是否是数组,如果是数组,则循环遍历它,并调 patch 函数挂载数组中的虚拟节点。

传递给 patch 函数的第一个参数是 null。因为是挂载阶段,没有旧 vnode,所以只需要传递 null 即可。传递给 patch 函数的第三个参数是挂载点。由于我们正在挂载的子元素是 div 标签的子节点,所以需要把刚刚创建的 div 元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

5)元素属性

为了描述元素的属性,我们需要为虚拟 DOM 定义新的 vnode.props 字段。

const vnode ={
type:
"div",//使用 props 描述一个元素的属性 props: {
id:
"foo"},
children: [
{
type:
"p",
children:
"hello"}
]
};

vnode.props 是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历 props 对象的方式, 把这些属性渲染到对应的元素上。

functionmountElement(vnode, container) {
const el
=createElement(vnode.type);//省略 children 的处理 //如果 vnode.props 存在才处理它 if(vnode.props) {//遍历 vnode.props for (const key invnode.props) {//调用 setAttribute 将属性设置到元素上 el.setAttribute(key, vnode.props[key]);
}
}
insert(el, container);
}

除了使用 setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置。实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象(如下所示),都存在缺陷,为元素设置属性比想象中要复杂得多。

el[key] = vnode.props[key]

HTML Attributes 指的就是定义在 HTML 标签上的属性,例如 id="my-input"、type="text"。当浏览器解析 HTML 代码后,会创建一个与之相符的 DOM 元素对象。

这个 DOM 对象会包含很多属性(properties),这些属性就是所谓的 DOM Properties。很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties,但 DOM Properties 与 HTML Attributes 的名字不总是一模一样。

例如 class="foo" 对应的 DOM Properties 则是 el.className。另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties。例如 aria-* 类的 HTML Attributes 就没有与之对应的 DOM Properties。

类似地,也不是所有的 DOM Properties 都有与之对应的 HTML Attributes,例如可以用 el.textContent 来设置元素的文本内容, 但并没有与之对应的 HTML Attributes 来完成同样的工作。

注意,渲染器不应该总是使用 setAttribute 函数将 vnode.props 对象中的属性设置到元素上。在浏览器中运行下面这句代码,发现浏览器仍然会将按钮禁用,因为使用 setAttribute 函数设置的值总是会被字符串化。

el.setAttribute('disabled', false)

要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。

functionmountElement(vnode, container) {
const el
=createElement(vnode.type);//省略 children 的处理 if(vnode.props) {for (const key invnode.props) {
const value
=vnode.props[key];//用 in 操作符判断 key 是否存在对应的 DOM Properties if (key inel) {//获取该 DOM Properties 的类型 const type = typeofel[key];//如果是布尔类型,并且 value 是空字符串,则将值矫正为 true if (type === "boolean" && value === "") {
el[key]
= true;
}
else{
el[key]
=value;
}
}
else{//如果要设置的属性没有对应的 DOM Properties,则使用setAttribute 函数设置属性 el.setAttribute(key, value);
}
}
}
insert(el, container);
}

检查每一个 vnode.props 中的属性, 看看是否存在对应的 DOM Properties,如果存在,则优先设置 DOM Properties。同时,我们对布尔类型的 DOM Properties 做了值的矫正, 即当要设置的值为空字符串时,将其矫正为布尔值 true。

但上面给出的实现仍然存在问题,因为有一些 DOM Properties 是只读的,例如 input 的 form 属性,在代码中增加特殊处理的判断,用函数替换上面第 8 行的判断条件。

functionshouldSetAsProps(el, key, value) {//特殊处理
  if (key === "form" && el.tagName === "INPUT") return false;//兜底
  return key inel;
}

这是一个特殊的例子,还有一些其他类似于这种需要特殊处理的情况。

只要在后续迭代过程中“见招拆招“,代码就会变得越来越完善,框架也会变得越来越健壮。

最后,我们需要把属性的设置也变成与平台无关,因此需要把属性设置相关操作也提取到渲染器选项中。

const renderer =createRenderer({
createElement(tag) {
returndocument.createElement(tag);
},
setElementText(el, text) {
el.textContent
=text;
},
insert(el, parent, anchor
= null) {
parent.insertBefore(el, anchor);
},
//将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递 patchProps(el, key, prevValue, nextValue) {if(shouldSetAsProps(el, key, nextValue)) {
const type
= typeofel[key];if (type === "boolean" && nextValue === "") {
el[key]
= true;
}
else{
el[key]
=nextValue;
}
}
else{
el.setAttribute(key, nextValue);
}
}
});

而在 mountElement 函数中,只需要调用 patchProps 函数, 并为其传递相关参数即可,查看
在线示例

functionmountElement(vnode, container) {//......
  if(vnode.props) {for (const key invnode.props) {//调用 patchProps 函数即可
      patchProps(el, key, null, vnode.props[key]);
}
}
//调用 insert 函数将元素插入到容器内 insert(el, container);
}

6)卸载

卸载操作发生在更新阶段,更新指的是在初次挂载完成之后,后续渲染会触发更新。

不能简单地使用 innerHTML 清空容器来完成卸载操作,原因有三:

  1. 容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数。
  2. 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。
  3. innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数。

正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除,修改 mountElement 函数,如下所示。

functionmountElement(vnode, container) {//让 vnode.el 引用真实 DOM 元素
  const el = vnode.el =createElement(vnode.type)//......
}

当我们调用 createElement 函数创建真实 DOM 元素时,会把真实 DOM 元素赋值给 vnode.el 属性。这样,在 vnode 与真实 DOM 元素之间就建立了联系,我们可以通过 vnode.el 来获取该虚拟节点所对应的真实 DOM 元素。

当卸载操作发生的时候,只需要根据虚拟节点对象 vnode.el 取得真实 DOM 元素,再将其从父元素中移除即可:

functionrender(vnode, container) {if(vnode) {
patch(container._vnode, vnode, container);
}
else{if(container._vnode) {//根据 vnode 获取要卸载的真实 DOM 元素 const el =container._vnode.el;//获取 el 的父元素 const parent =el.parentNode;//调用 removeChild 移除元素 if(parent) parent.removeChild(el);
}
}
container._vnode
=vnode;
}

其中 container._vnode 代表旧 vnode, 即要被卸载的 vnode。然后通过 container._vnode.el 取得真实 DOM 元素,并调用 removeChild 函数将其从父元素中移除即可。

由于卸载操作是比较常见且基本的操作,所以我们应该将它封装到 unmount 函数中,以便后续代码可以复用。

functionunmount(vnode) {
const parent
=vnode.el.parentNode;if(parent) {
parent.removeChild(vnode.el);
}
}

现在 unmount 函数的代码还非常简单,后续我们会慢慢充实它,让它变得更加完善。在 render 函数中调用它来完成卸载任务了,查看
在线示例

functionrender(vnode, container) {if(vnode) {//......
  } else{if(container._vnode) {//调用 unmount 函数卸载 vnode
unmount(container._vnode);
}
}
//...... }

在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,还可以检测虚拟节点 vnode 的类型,若是组件,能调用其生命周期函数。

7)更新子节点

元素的子节点都有哪些情况,如下面的 HTML 代码所示:

<!--没有子节点-->
<div></div>
<!--文本子节点-->
<div>Some Text</div>05<!--多个子节点-->
<div>
  <p/>
  <p/>
</div>

既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。所以,我们可以总结出更新子节点时的九种可能。

接下来我们就开始着手实现,patchElement 函数的代码如下所示:

functionpatchElement(n1, n2) {
const el
= (n2.el =n1.el);
const oldProps
=n1.props;
const newProps
=n2.props;//第一步:更新 props for (const key innewProps) {if (newProps[key] !==oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
for (const key inoldProps) {if (!(key innewProps)) {
patchProps(el, key, oldProps[key],
null);
}
}
//第二步:更新 children patchChildren(n1, n2, el);
}

更新子节点是对一个元素进行打补丁的最后一步操作。我们将它封装到 patchChildren 函数中,并将新旧 vnode 以及当前正在被打补丁的 DOM 元素 el 作为参数传递给它。

functionpatchChildren(n1, n2, container) {//判断新子节点的类型是否是文本节点
  if (typeof n2.children === "string") {//旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点
    //只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
    if(Array.isArray(n1.children)) {
n1.children.forEach((c)
=>unmount(c));
}
//最后将新的文本节点内容设置给容器元素 setElementText(container, n2.children);
}
}

如果新子节点的类型不是文本子节点,我们需要再添加一个判断分支,判断它是否是一组子节点。

functionpatchChildren(n1, n2, container) {//判断新子节点的类型是否是文本节点
  if (typeof n2.children === "string") {//......
    //新子节点是一组子节点
  } else if(Array.isArray(n2.children)) {//判断旧子节点是否也是一组子节点
    if(Array.isArray(n1.children)) {//代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法,暂时省略
    } else{//此时,旧子节点要么是文本子节点,要么不存在
      //但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
      setElementText(container, "");
n2.children.forEach((c)
=> patch(null, c, container));
}
}
}

如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,这里就涉及我们常说的 Diff 算法。

如果要简单点,那么可以先直接卸载全部旧子节点,再挂载全部新子节点。

8)Diff算法

当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。

操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

在上一节中, 采用了一种简单粗暴的更新方式,不仅对 DOM 元素无法进行复用,还需要大量的 DOM 操作才能完成更新,这样会产生极大的性能开销。

对其进行改进后的方案是,遍历新旧两组子节点中数量较少的那一组,并逐个调用 patch 函数进行打补丁,然后比较新旧两组子节点的数量,如果新的一组子节点数量更多,那么说明有新子节点需要挂载;否则说明在旧的一组子节点中,有节点需要卸载。

而在虚拟节点中有个 key 属性,它就像虚拟节点的“身份证号”。在更新时,渲染器通过 key 属性找到可复用的节点,然后尽可能地通过 DOM 移动操作来完成更新,避免过多地对 DOM 元素进行销毁和重建。

其实,简单 Diff 算法就是在寻找需要移动的节点的,其逻辑就是拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。

简单 Diff 算法的问题在于,它对 DOM 的移动操作并不是最优的。双端 Diff 算法会在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。对于同样的更新场景,其执行的 DOM 移动操作次数将更少。

第三种用于比较新旧两组子节点的方式叫快速 Diff 算法。该算法最早应用于 ivi 和 inferno 这两个框架,Vue.js 3 借鉴并扩展了它,快速 Diff 算法在实测中性能最优。

它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长递增子序列所指向的节点即为不需要移动的节点。

四、编译器

Vue.js 的模板和 JSX 都属于领域特定语言(DSL),它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。

编译器其实只是一段程序,它用来将一种语言 A 翻译成另外一种语言 B。其中,语言 A 通常叫作 (source code),语言 B 通常叫做目标代码(object code 或 target code)。

编译器将源代码翻译为目标代码的过程叫作编译(compile)。完整的编译过程通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤。

整个编译过程分为编译前端和编译后端。编译前端包含词法分析、语法分析和语义分析,它通常与目标平台无关,仅负责分析源代码。

编译后端则通常与目标平台有关,编译后端涉及中间代码生成和优化以及目标代码生成。但是,编译后端并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。中间代码生成和优化这两个环节有时也叫“中端”。

对于 Vue.js 模板编译器来说,源代码就是组件的模板,而目标代码是能够在浏览器平台上运行的 JavaScript 代码,或其他拥有 JavaScript 运行时的平台代码。

Vue.js 模板编译器的目标代码其实就是渲染函数,详细而言:

  1. Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。
  2. 将模板 AST(transform)成 JavaScript AST。
  3. 根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。

1)模板 AST

AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。所谓模板 AST,其实就是用来描述模板的抽象语法树。假设我们有如下模板:

<div>
  <h1v-if="ok">Vue Template</h1>
</div>

这段模板会被编译为如下所示的 AST:

const ast ={//逻辑根节点
  type: "Root",
children: [
//div 标签节点 {
type:
"Element",
tag:
"div",
children: [
//h1 标签节点 {
type:
"Element",
tag:
"h1",
props: [
//v-if 指令节点 {
type:
"Directive", //类型为 Directive 代表指令 name: "if", //指令名称为 if,不带有前缀 v- exp: {//表达式节点 type: "Expression",
content:
"ok"}
}
]
}
]
}
]
};

AST 其实就是一个具有层级结构的对象。模板 AST 具有与模板同构的嵌套结构。每一棵 AST 都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root 节点的 children 存在。

我们可以通过封装 parse 函数来完成对模板的词法分析和语法分析,得到模板 AST。

const template =`<div>
   <h1 v-if="ok">Vue Template</h1>
 </div>
`;
const templateAST
= parse(template);

parse 函数接收字符串模板作为参数,并将解析后得到的 AST 作为结果返回。在语义分析的基础上,我们即可得到模板 AST。

2)JavaScript AST

我们还需要将模板 AST 转换为 JavaScript AST。因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST。

例如描述一个渲染函数的声明,其基本的数据结构如下:

//函数声明
functionrender() {return h("div", [h("p", "Vue"), h("p", "Template")]);
}
//JavaScript AST const FunctionDeclNode ={
type:
"FunctionDecl", //代表该节点是函数声明 //函数的名称是一个标识符,标识符本身也是一个节点 id: {
type:
"Identifier",
name:
"render" //name 用来存储标识符的名称,在这里它就是渲染函数的名称 render },
params: [],
//参数,目前渲染函数还不需要参数,所以这里是一个空数组 //渲染函数的函数体只有一个语句,即 return 语句 body: [
{
type:
"ReturnStatement",return: null //暂时留空 }
]
};

我们可以封装 transform 函数来完成模板 AST 到 JavaScript AST 的转换工作。

const templateAST =parse(template)
const jsAST
= transform(templateAST)

3)代码生成

代码生成本质上是字符串拼接的艺术,访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。

接上文,在有了 JavaScript AST 后,我们就可以根据它生成渲染函数了,这一步可以通过封装 generate 函数来完成。

const templateAST =parse(template)
const jsAST
=transform(templateAST)
const code
= generate(jsAST)

在上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回。

functiongenerate(node) {
const context
={//存储最终生成的渲染函数代码 code: "",//在生成代码时,通过调用 push 函数完成代码的拼接 push(code) {
context.code
+=code;
}
};
//调用 genNode 函数完成代码生成的工作, genNode(node, context);//返回渲染函数代码 returncontext.code;
}

首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码,还定义了 context.push 函数,用来完成代码拼接,接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。

genNode 函数用于完成代码生成的工作。而代码生成的原理其实很简单,只需要匹配各种类型的 JavaScript AST 节点,并调用对应的生成函数即可。

functiongenNode(node, context) {switch(node.type) {case "FunctionDecl":
genFunctionDecl(node, context);
break;case "ReturnStatement":
genReturnStatement(node, context);
break;case "CallExpression":
genCallExpression(node, context);
break;case "StringLiteral":
genStringLiteral(node, context);
break;case "ArrayExpression":
genArrayExpression(node, context);
break;
}
}

在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。当然,如果后续需要增加节点类型,只需要在 genNode 函数中添加相应的处理分支即可。

接下来,我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数。

functiongenFunctionDecl(node, context) {//从 context 对象中取出工具函数
  const { push, indent, deIndent } =context;//node.id 是一个标识符,用来描述函数的名称,即 node.id.name
  push(`function${node.id.name} `);
push(`(`);
//调用 genNodeList 为函数的参数生成代码 genNodeList(node.params, context);
push(`) `);
push(`{`);
//缩进 indent();//为函数体生成代码,这里递归地调用了 genNode 函数 node.body.forEach((n) =>genNode(n, context));//取消缩进 deIndent();
push(`}`);
}

genFunctionDecl 函数用来为函数声明类型的节点生成对应的 JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:

functionrender () {//... 函数体
}

在 genFunctionDecl 函数内部调用了 genNodeList 函数来为函数的参数生成对应的代码。它的实现如下:

functiongenNodeList(nodes, context) {
const { push }
=context;for (let i = 0; i < nodes.length; i++) {
const node
=nodes[i];
genNode(node, context);
if (i < nodes.length - 1) {
push(
", ");
}
}
}

genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。这里要注意的一点是, 每处理完一个节点,需要在生成的代码后面拼接逗号字符(,)。

//如果节点数组为
const node = [节点 1,节点 2,节点 3]//那么生成的代码将类似于
'节点 1,节点 2,节点 3'
//如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明
('节点 1,节点 2,节点 3')//如果在这段代码的前后分别添加方括号,那么它将是一个数组
['节点 1,节点 2,节点 3']

其余几个生成器函数的实现可以参考书籍 P406 的内容。

五、同构

Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。

这实际上描述了 Vue.js 的两种渲染方式,即客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)。另外, Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphic rendering)。

与 SSR 相比,CSR 会产生所谓的白屏问题。实际上,CSR 不仅仅会产生白屏问题,它对 SEO(搜索引擎优化)也不友好。不过 SSR 是在服务端完成页面渲染的,所以它需要消耗更多服务端资源。CSR 则能够减少对服务端资源的消耗。并且由于 CSR 不需要进行真正的跳转,用户会感觉更加流畅。

从这些角度来看,无论是 SSR 还是 CSR,都不可以作为银弹,我们需要从项目的实际需求出发,决定到底采用哪一个。那么,我们能否融合 SSR 与 CSR 两者的优点于一身呢?答案是“可以的”,这就是接下来我们要讨论的同构渲染。

1)同构渲染

同构渲染分为首次渲染(即首次访问或刷新页面)以及非首次渲染。

同构渲染中的首次渲染与 SSR 的工作流程是一致的。也就是说,当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。另外,该静态的 HTML 页面中也会包含 <link>、<script> 等标签。

除此之外,同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML 页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据。直白地说,服务器通过 API 请求的数据会被序列化为字符串,并拼接到静态的 HTML 字符串中,最后一并发送给浏览器,这么做实际上是为了后续的激活操作。

假设浏览器已经接收到初次渲染的静态 HTML 页面,接下来浏览器会解析并渲染该页面。在解析过程中,浏览器会发现 HTML 代码中存在 <link> 和 <script> 标签,于是会从 CDN 或服务器获取相应的资源,这一步与 CSR 一致。

当 JavaScript 资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的水合(hydration)。激活包含两部分工作内容。

  1. Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。
  2. Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。后续操作都会按照 CSR 应用程序的流程来执行。

同构渲染除了也需要部分服务端资源外,其他方面的表现都非常棒。 另外,对同构渲染最多的误解是,它能够提升可交互时间(TTI)。事实是同构渲染仍然需要像 CSR 那样等待 JavaScript 资源加载完成,并且客户端激活完成后,才能响应用户操作。因此,理论上同构渲染无法提升可交互时间。

同构渲染的“同构”一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。例如,我们用 Vue.js 编写一个组件,该组件既可以在服务端运行,被渲染为 HTML 字符串;也可以在客户端运行,就像普通的 CSR 应用程序一样。

2)渲染为HTML字符串

给出如下虚拟节点对象,它用来描述一个普通的 div 标签:

const ElementVNode ={
type:
"div",
props: {
id:
"foo"},
children: [{ type:
"p", children: "hello"}]
};

为了将虚拟节点 ElementVNode 渲染为字符串,我们需要实现 renderElementVNode 函数。该函数接收用来描述普通标签的虚拟节点作为参数,并返回渲染后的 HTML 字符串。

在不考虑任何边界条件的情况下,实现 renderElementVNode 非常简单,如下面的代码所示:

functionrenderElementVNode(vnode) {//取出标签名称 tag 和标签属性 props,以及标签的子节点
  const { type: tag, props, children } =vnode;//开始标签的头部
  let ret = `<${tag}`;//处理标签属性
  if(props) {for (const k inprops) {//以 key="value" 的形式拼接字符串
      ret += ` ${k}="${props[k]}"`;
}
}
//开始标签的闭合 ret += `>`;//处理子节点 //如果子节点的类型是字符串,则是文本内容,直接拼接 if (typeof children === "string") {
ret
+=children;
}
else if(Array.isArray(children)) {//如果子节点的类型是数组,则递归地调用 renderElementVNode 完成渲染 children.forEach((child) =>{
ret
+=renderElementVNode(child);
});
}
//结束标签 ret += `</${tag}>`; //返回拼接好的 HTML 字符串 returnret;
}

接着,我们可以调用 renderElementVNode 函数完成对 ElementVNode 的渲染,查看
在线示例

//<div id="foo"><p>hello</p></div>
console.log(renderElementVNode(ElementVNode));

实际上,将一个普通标签类型的虚拟节点渲染为 HTML 字符串,本质上是字符串的拼接。不过,上面给出的函数仅仅用来展示核心原理,并不满足生产要求。因为它存在以下四点缺陷,而这些都属于边界条件。

  1. 函数在渲染标签类型的虚拟节点时,还需要考虑该节点是否是自闭合标签,术语叫作 void element,例如 input、link、meta 等。
  2. 对于属性(props)的处理会比较复杂,要考虑属性名称是否合法,还要对属性值进行 HTML 转义。
  3. 子节点的类型多种多样,可能是任意类型的虚拟节点,如 Fragment、组件、函数式组件、文本等,这些都需要处理。
  4. 标签的文本子节点也需要进行 HTML 转义,即将特殊字符转换为对应的 HTML 实体,例如 < 转义为实体 &lt;。

把组件渲染为 HTML 字符串与把普通标签节点渲染为 HTML 字符串并没有本质区别。组件的渲染函数用来描述组件要渲染的内容,它的返回值也是虚拟 DOM。所以,我们只需要执行组件的渲染函数取得对应的虚拟 DOM,再将该虚拟 DOM 渲染为 HTML 字符串,并作为 renderComponentVNode 函数的返回值即可。

3)激活

对于同构渲染来说,组件的代码会在服务端和客户端分别执行一次。在服务端,组件会被渲染为静态的 HTML 字符串,然后发送给浏览器,浏览器再把这段纯静态的 HTML 渲染出来。

由于浏览器在渲染了由服务端发送过来的 HTML 字符串之后,页面中已经存在对应的 DOM 元素,所以组件代码在客户端运行时,不需要再次创建相应的 DOM 元素。但是,组件代码在客户端运行时,仍然需要做两件重要的事:

  1. 在页面中的 DOM 元素与虚拟节点对象之间建立联系。
  2. 为页面中的 DOM 元素添加事件绑定。

实际上,我们可以用代码模拟从服务端渲染到客户端激活的整个过程,如下所示:

//html 代表由服务端渲染的字符串
const html =renderComponentVNode(compVNode);//假设客户端已经拿到了由服务端渲染的字符串//获取挂载点
const container = document.querySelector("#app");//设置挂载点的 innerHTML,模拟由服务端渲染的内容
container.innerHTML =html;//接着调用 hydrate 函数完成激活
renderer.hydrate(compVNode, container);

其中 CompVNode 的代码如下:

const MyComponent ={
name:
"App",
setup() {
const str
= ref("foo");return () =>{return{
type:
"div",
children: [
{
type:
"span",
children: str.value,
props: {
onClick: ()
=>{
str.value
= "bar";
}
}
},
{ type:
"span", children: "baz"}
]
};
};
}
};
const CompVNode
={
type: MyComponent
};

与 renderer.render 函数一样,renderer.hydrate 函数也是渲染器的一部分,因此它也会作为 createRenderer 函数的返回值。

functioncreateRenderer(options) {functionrender(vnode, container) { }functionhydrate(vnode, container) { }return{
render,
hydrate
};
}

真实 DOM 元素与虚拟 DOM 对象都是树型结构,并且节点之间存在一一对应的关系。因此,我们可以认为它们是“同构”的。而激活的原理就是基于这一事实,递归地在真实 DOM 元素与虚拟 DOM 节点之间建立关系。

另外,在虚拟 DOM 中并不存在与容器元素(或挂载点)对应的节点。因此,在激活的时候,应该从容器元素的第一个子节点开始:

functionhydrate(vnode, container) {//从容器元素的第一个子节点开始
hydrateNode(container.firstChild, vnode);
}

其中,hydrateNode 函数接收两个参数,分别是真实 DOM 元素和虚拟 DOM 元素。

functionhydrateNode(node, vnode) {
const { type }
=vnode;//1. 让 vnode.el 引用真实 DOM vnode.el =node;//2. 检查虚拟 DOM 的类型,如果是组件,则调用 mountComponent 函数完成激活 if (typeof type === "object") {
mountComponent(vnode, container,
null);
}
else if (typeof type === "string") {//3. 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配 if (node.nodeType !== 1) {
console.error(
"mismatch");
console.error(
"服务端渲染的真实 DOM 节点是:", node);
console.error(
"客户端渲染的虚拟 DOM 节点是:", vnode);
}
else{//4. 如果是普通元素,则调用 hydrateElement 完成激活 hydrateElement(node, vnode)
}
}
//5. 重要:hydrateNode 函数需要返回当前节点的下一个兄弟节点,以便继续 进行后续的激活操作 returnnode.nextSibling;
}

首先,要在真实 DOM 元素与虚拟DOM元素之间建立联系,即 vnode.el = node。这样才能保证后续更新操作正常进行。

其次,我们需要检测虚拟 DOM 的类型, 并据此判断应该执行怎样的激活操作。对于普通元素的激活操作,则可以通过 hydrateElement 函数来完成。

最后,hydrateNode 函数需要返回当前激活节点的下一个兄弟节点,以便进行后续的激活操作。

hydrateNode 函数的返回值非常重要,它的用途体现在 hydrateElement 函数内,如下面的代码所示:

//用来激活普通元素类型的节点
functionhydrateElement(el, vnode) {//1. 为 DOM 元素添加事件
  if(vnode.props) {for (const key invnode.props) {//只有事件类型的 props 需要处理
      if (/^on/.test(key)) {
patchProps(el, key,
null, vnode.props[key]);
}
}
}
//递归地激活子节点 if(Array.isArray(vnode.children)) {//从第一个子节点开始 let nextNode =el.firstChild;
const len
=vnode.children.length;for (let i = 0; i < len; i++) {//激活子节点,注意,每当激活一个子节点,hydrateNode 函数都会返回当前子节点的下一个兄弟节点, //于是可以进行后续的激活了 nextNode =hydrateNode(nextNode, vnode.children[i]);
}
}
}

hydrateElement 函数有两个关键点。

  1. 因为服务端渲染是忽略事件的,浏览器只是渲染了静态的 HTML 而已,所以激活 DOM 元素的操作之一就是为其添加事件处理程序。
  2. 递归地激活当前元素的子节点,从第一个子节点 el.firstChild 开始,递归地调用 hydrateNode 函数完成激活。

对于组件的激活,我们还需要针对性地处理 mountComponent 函数。由于服务端渲染的页面中已经存在真实 DOM 元素,所以当调用 mountComponent 函数进行组件的挂载时,无须再次创建真实 DOM 元素。