2024年3月

mydocker-ps.png

本文为从零开始写 Docker 系列第九篇,实现类似 docker ps 的功能,使得我们能够查询到后台运行中的所有容器。


完整代码见:
https://github.com/lixd/mydocker
欢迎 Star

推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.2 LTS
Release:	20.04
Codename:	focal
root@mydocker:~# uname -r
5.4.0-74-generic


注意:需要使用 root 用户

1. 概述

上一篇已经实现了
mydocker run -d
命令,可以让容器脱离父进程在后台独立运行。

那么我们怎么知道有哪些容器在运行,而且它们的信息又是什么呢?

这里就需要实现 mydocker ps 命令了。其实 mydocker ps 命令比较简单,主要是去约定好的位置查询一下容器的信息数据,然后显示出来,因此数据准备就显得尤为重要。

因此整个实现分为两部分:

  • 1)容器运行时记录数据
  • 2)mydocker ps 查询数据

对于 docker 来说,他会把容器信息存储在
var/lib/docker/containers
目录下。

  • 读取
    var/lib/docker/containers
    目录下的所有文件夹就能拿到当前系统中的容器
  • 读取
    /var/lib/docker/containers/{containerID}/config.v2.json
    文件即可拿到对应容器的详细信息。

我们也参考着 Docker 实现即可。

2. 记录容器信息

在前面章节创建的容器中,所有关于容器的信息,比如PID、容器创建时间、容器运行命令等,都没有记录,这导致容器运行完后就再也不知道它的信息了,因此需要把这部分信息保留下来。

具体实现则是创建容器时将相关信息写入
/var/lib/mydocker/containers/{containerId}/config.json
文件中。

具体流程如下图所示:

record-container-info.png

提供 -name flag

首先,要在 runCommand flag 里面增加一个 name 标签,方便用户启动容器时指定容器的名字。

var runCommand = cli.Command{
    Name: "run",
    Usage: `Create a container with namespace and cgroups limit
          mydocker run -it [command]`,
    Flags: []cli.Flag{
       // 省略其他内容
       cli.StringFlag{
          Name:  "name",
          Usage: "container name",
       },
    },
        Action: func(context *cli.Context) error {
       // 把namne传递给Run方法
       containerName := context.String("name")
       Run(tty, cmdArray, resConf, volume, containerName)
       return nil
    },

recordContainerInfo

然后,需要增加一个 record 方法记录容器的相关信息。在增加之前,需要一个 ID 生成器,用来唯一标识容器。

使用过 Docker 的都知道,每个容器都会有一个 ID,为了方便起见,mydocker 中就用 10 位数字来表示一个容器的 ID。

func randStringBytes(n int) string {
    letterBytes := "1234567890"
    rand.Seed(time.Now().UnixNano())
    b := make([]byte, n)
    for i := range b {
       b[i] = letterBytes[rand.Intn(len(letterBytes))]
    }
    return string(b)
}

另外就是记录容器信息这个重要的环节,我们先定义了一个容器的一些基本信息,比如 PID 和创建时间等,然后默认把容器的信息以 json 的形式存储在宿主机的
/var/run/mydocker/容器名/config.json
文件里面。

容器完整信息的基本格式如下:

type Info struct {
    Pid         string `json:"pid"`        // 容器的init进程在宿主机上的 PID
    Id          string `json:"id"`         // 容器Id
    Name        string `json:"name"`       // 容器名
    Command     string `json:"command"`    // 容器内init运行命令
    CreatedTime string `json:"createTime"` // 创建时间
    Status      string `json:"status"`     // 容器的状态
}

然后就开始记录容器信息:

func RecordContainerInfo(containerPID int, commandArray []string, containerName, containerId string) error {
	// 如果未指定容器名,则使用随机生成的containerID
	if containerName == "" {
		containerName = containerId
	}
	command := strings.Join(commandArray, "")
	containerInfo := &Info{
		Id:          containerId,
		Pid:         strconv.Itoa(containerPID),
		Command:     command,
		CreatedTime: time.Now().Format("2006-01-02 15:04:05"),
		Status:      RUNNING,
		Name:        containerName,
	}

	jsonBytes, err := json.Marshal(containerInfo)
	if err != nil {
		return errors.WithMessage(err, "container info marshal failed")
	}
	jsonStr := string(jsonBytes)
	// 拼接出存储容器信息文件的路径,如果目录不存在则级联创建
	dirPath := fmt.Sprintf(InfoLocFormat, containerId)
	if err := os.MkdirAll(dirPath, constant.Perm0622); err != nil {
		return errors.WithMessagef(err, "mkdir %s failed", dirPath)
	}
	// 将容器信息写入文件
	fileName := path.Join(dirPath, ConfigName)
	file, err := os.Create(fileName)
	defer file.Close()
	if err != nil {
		return errors.WithMessagef(err, "create file %s failed", fileName)
	}
	if _, err = file.WriteString(jsonStr); err != nil {
		return errors.WithMessagef(err, "write container info to  file %s failed", fileName)
	}
	return nil
}

实际就是把容器的信息序列化之后持久化到磁盘的
/var/run/{containerID}/config.json
文件里。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


Run 方法修改

最后,在 Run 函数上加上对于这个函数的调用,代码如下:

func Run(tty bool, comArray []string, res *subsystems.ResourceConfig, volume, containerName string) {
	containerId := container.GenerateContainerID() // 生成 10 位容器 id

	parent, writePipe := container.NewParentProcess(tty, volume)
	if parent == nil {
		log.Errorf("New parent process error")
		return
	}
	if err := parent.Start(); err != nil {
		log.Errorf("Run parent.Start err:%v", err)
		return
	}

	// record container info
	err := container.RecordContainerInfo(parent.Process.Pid, comArray, containerName, containerId)
	if err != nil {
		log.Errorf("Record container info error %v", err)
		return
	}

	// 创建cgroup manager, 并通过调用set和apply设置资源限制并使限制在容器上生效
	cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
	defer cgroupManager.Destroy()
	_ = cgroupManager.Set(res)
	_ = cgroupManager.Apply(parent.Process.Pid, res)

	// 在子进程创建后才能通过pipe来发送参数
	sendInitCommand(comArray, writePipe)
	if tty { // 如果是tty,那么父进程等待,就是前台运行,否则就是跳过,实现后台运行
		_ = parent.Wait()
		container.DeleteWorkSpace("/root/", volume)
		container.DeleteContainerInfo(containerId)
	}
}

另外再容器退出后,就需要删除容器的相关信息,实现也很简单,把对应目录的信息都删除就好了。

func DeleteContainerInfo(containerID string) {
	dirPath := fmt.Sprintf(InfoLocFormat, containerID)
	if err := os.RemoveAll(dirPath); err != nil {
		log.Errorf("Remove dir %s error %v", dirPath, err)
	}
}

到此为止,就完成了信息的收集。容器创建后,所有需要的信息都被存储到
/var/lib/mydocker/containers/{containerID}
下,下面就可以通过读取并遍历这个目录下的容器去实现 mydocker ps 命令了。

3. 实现 mydocker ps

具体实现则是遍历
/var/lib/mydocker/containers/
目录,解析得到容器信息并汇总后以表格形式打印出来。

具体流程如下图所示:

mydocker-ps-exec-process.png

listCommand

首先在 main_command.go 中增加 ps 命令:

var listCommand = cli.Command{
    Name:  "ps",
    Usage: "list all the containers",
    Action: func(context *cli.Context) error {
       ListContainers()
       return nil
    },
}

在 main.go 中引用该命令:

func main {
    // 省略其他内容
    app.Commands = []cli.Command{
       initCommand,
       runCommand,
       commitCommand,
       listCommand,
    }
}

具体实现见 ListContainers 方法。

ListContainers

整体实现也比较简单:

  • 首先遍历存放容器数据的
    /var/lib/mydocker/containers/
    目录,里面每一个子目录都是一个容器。
  • 然后使用 getContainerInfo 方法解析子目录中的
    config.json
    文件拿到容器信息
  • 最后格式化成 table 形式打印出来即可
func ListContainers() {
	// 读取存放容器信息目录下的所有文件
	files, err := os.ReadDir(container.InfoLoc)
	if err != nil {
		log.Errorf("read dir %s error %v", container.InfoLoc, err)
		return
	}
	containers := make([]*container.Info, 0, len(files))
	for _, file := range files {
		tmpContainer, err := getContainerInfo(file)
		if err != nil {
			log.Errorf("get container info error %v", err)
			continue
		}
		containers = append(containers, tmpContainer)
	}
	// 使用tabwriter.NewWriter在控制台打印出容器信息
	// tabwriter 是引用的text/tabwriter类库,用于在控制台打印对齐的表格
	w := tabwriter.NewWriter(os.Stdout, 12, 1, 3, ' ', 0)
	_, err = fmt.Fprint(w, "ID\tNAME\tPID\tSTATUS\tCOMMAND\tCREATED\n")
	if err != nil {
		log.Errorf("Fprint error %v", err)
	}
	for _, item := range containers {
		_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
			item.Id,
			item.Name,
			item.Pid,
			item.Status,
			item.Command,
			item.CreatedTime)
		if err != nil {
			log.Errorf("Fprint error %v", err)
		}
	}
	if err = w.Flush(); err != nil {
		log.Errorf("Flush error %v", err)
	}
}

getContainerInfo

具体的解析方法则提取到了
getContainerInfo

读取文件内容,并反序列化得到容器信息。

func getContainerInfo(file os.DirEntry) (*container.Info, error) {
	// 根据文件名拼接出完整路径
	configFileDir := fmt.Sprintf(container.InfoLocFormat, file.Name())
	configFileDir = path.Join(configFileDir, container.ConfigName)
	// 读取容器配置文件
	content, err := os.ReadFile(configFileDir)
	if err != nil {
		log.Errorf("read file %s error %v", configFileDir, err)
		return nil, err
	}
	info := new(container.Info)
	if err = json.Unmarshal(content, info); err != nil {
		log.Errorf("json unmarshal error %v", err)
		return nil, err
	}

	return info, nil
}

4. 测试

测试以下功能:

  • 创建容器后能否记录信息到文件
  • mydocker ps 能否正常读取并展示容器信息

记录容器信息

分别测试指定容器名称和不知道名称两种情况。

指定名称

通过
--name
指定容器名称,并通过
-d
指定后台运行:

root@mydocker:~/feat-ps/mydocker# ./mydocker run -d -name runtop top
{"level":"info","msg":"createTty false","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-25T14:20:11+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-25T14:20:11+08:00"}

可以看到此时,命令已经退出了,查询容器(top 命令)是否在后台运行。

root@mydocker:~/feat-ps/mydocker# ps -ef|grep -e PPID -e top
UID          PID    PPID  C STIME TTY          TIME CMD
root      169514       1  0 14:20 pts/8    00:00:00 top

后台确实有一个 top 命令在运行,PID 为 169514。

查看
/var/lib/mydocker/containers
目录,是否新增了容器信息记录文件

root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers
5633481844
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers/5633481844/
config.json
root@mydocker:~/feat-ps/mydocker# cat /var/lib/mydocker/containers/5633481844/config.json
{"pid":"169514","id":"5633481844","name":"runtop","command":"top","createTime":"2024-01-25 14:20:11","status":"running"}

可以看到,
config.json
文件记录了容器名称,id、pid、command 等信息,基于这些信息,我们执行
mydocker ps
时就可以列出当前正在运行的容器信息了。

不指定名称

在测试一下不指定名称的容器,能否正常记录。

root@mydocker:~/feat-ps/mydocker# ./mydocker run -d  top
{"level":"info","msg":"createTty false","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-25T14:22:28+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-25T14:22:28+08:00"}

查看
/var/lib/mydocker/containers
目录是否新增记录文件

root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers
5633481844  8636128862
root@mydocker:~/feat-ps/mydocker# ls /var/lib/mydocker/containers/8636128862/
config.json
root@mydocker:~/feat-ps/mydocker# cat /var/lib/mydocker/containers/8636128862/config.json
{"pid":"169707","id":"8636128862","name":"8636128862","command":"top","createTime":"2024-01-25 14:22:28","status":"running"

可以看到,新增了 8636128862 目录,其中 8636128862 就是容器 ID,对于未指定名称的容器,会使用生成的 id 作为名称。

接着查看一下
/var/lib/mydocker/containers
目录结构:

root@mydocker:/var/lib/mydocker/containers# tree .
.
├── 5633481844
│   └── config.json
└── 8636128862
    └── config.json

可以看到,mydocker 分别在该路径下创建了两个文件夹,分别以容器的ID命名。

子目录里面的config.json 存储了容器的详细信息。

至此,说明我们的容器信息记录功能是正常的。

mydocker ps

最后测试
mydocker ps
命令能否正常展示,容器信息。

root@mydocker:~/feat-ps/mydocker# ./mydocker ps
ID           NAME         PID         STATUS      COMMAND     CREATED
5633481844   runtop       169514      running     top         2024-01-25 14:20:11
8636128862   8636128862   169707      running     top         2024-01-25 14:22:28

成功打印出了当前运行中的两个容器,说明
mydocker ps
命令是 ok 的。

5. 总结

本篇实现的
mydocker ps
比较简单,和 docker 实现基本类似:

  • 容器启动把信息存储在
    var/lib/mydocker/containers
    目录下

  • 读取
    var/lib/mydocker/containers
    目录下的所有文件夹就能拿到当前系统中的容器

  • 读取
    /var/lib/mydocker/containers/{containerID}/config.json
    文件即可拿到对应容器的详细信息。

不过现在由于没有隔离每个容器的 rootfs,因此启动多个容器时会出现一些问题,不过不是本篇重点,暂时先不关注,等后续统一处理。


【从零开始写 Docker 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。




完整代码见:
https://github.com/lixd/mydocker
欢迎关注~

相关代码见
feat-volume
分支,测试脚本如下:

需要提前在 /root 目录准备好 busybox.tar 文件,具体见第四篇第二节。

# 克隆代码
git clone -b feat-ps https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 
./mydocker run -d -name c1 top

从字面意思上看,代理就是代替处理的意思,一个对象有能力代替另一个对象处理某一件事。

代理,这个词在我们的日常生活中也不陌生,比如在购物、旅游等场景中,我们经常会委托别人代替我们完成某些任务。在技术领域,这个概念也被广泛应用,尤其是在计算机网络通信和程序设计中,代理扮演着相当重要的角色,涉及控制访问、安全保护、能力扩展等复杂而强大的方面。

网络通信中的代理

在计算机网络中,说到代理,经常会谈到正向代理和反向代理的概念。

在详细展开前,我们先使用一个比喻来形象的理解下这两个概念:小明去饭馆吃饭,正向代理就像是小明的朋友帮他去点餐,服务员并不知道最终吃饭的人是小明;而反向代理则像是饭馆的服务员,他们决定把小明的订单送到哪个厨师手里去做。通过这个比喻,我们可以初步感受到正向代理和反向代理在角色和功能上的不同。

搞清楚网络通信中的代理和反向代理,大家只要弄明白两件事:你在公司的电脑是怎么访问到外网的,你部署的网站或者API又是怎么被外网访问到的。

公司电脑上网

首先看公司电脑上网:公司里的电脑一般不会直接连接到互联网,它们通常在一个内网环境中,这既有成本的考虑,也有安全控制的需要。办公电脑一般会先连接到交换机,交换机再连接到路由器,路由器再连接到互联网。

在这些连接中,交换机只是一个小透明,办公电脑可以看到路由器,路由器也可以看到办公电脑,所以交换机不是我们这里所说的代理。

这里真正的代理是路由器,办公电脑访问网络时,请求先到达路由器,路由器做个请求来源的登记,记下这个请求是从哪台电脑发出的,然后再发到互联网上。请求出了路由器,互联网上能够看到的就是这个路由器,而看不到你的办公电脑。数据从远程服务器返回时,也是先到达这个路由器,路由器再根据之前做的请求来源登记,将数据转发到对应的办公电脑上。

这种场景下,路由器就是一个正向代理,代理内网电脑访问互联网。

除了使用路由器这种比较常见的代理方式,其实还有很多方式,比如在浏览器中配置HTTP代理,只允许通过浏览器访问外网。

网站被外网访问

再看网站或者API是怎么被外网访问到的:通常情况下,大家的服务器也是放在内网中的,直接暴露在互联网上会有安全风险,也不利于管理。所以,我们会在服务器和互联网之间设置一个代理服务器,通常是Nginx或者LVS这种负载均衡器。当外网的用户想要访问你的网站或API时,他们的请求首先会发送到这个代理服务器上。

这个代理服务器就是一个反向代理。

反向代理服务器接到请求后,它知道内网中哪台服务器能提供这个服务,于是它就把请求转发给对应的服务器。服务器处理完这个请求后,再把结果发送回反向代理服务器,最后由反向代理服务器返回给外网的用户。

对比

以上就是计算机网络中正向代理和反向代理的基本原理和应用场景,我们再做一个对比,加深印象。

正向代理和反向代理的区别主要体现在它们服务的对象和用途上:

对比项

正向代理(Forward Proxy)

反向代理(Reverse Proxy)

服务对象

客户端

服务器

主要用途

- 帮助客户端访问无法直接访问的资源

- 进行访问控制和缓存以提高速度和安全性

- 隐藏服务器真实IP地址

- 提供负载均衡功能

- 提高服务器访问速度和安全性

工作方式

- 客户端配置代理服务器,请求先发送至代理服务器

- 代理服务器代为访问目标服务器并返回资源给客户端

- 客户端请求发送至反向代理服务器

- 反向代理服务器根据配置转发请求到内部网络的特定服务器

- 从服务器获取响应后返回给客户端

举例说明

- 使用浏览器设置代理服务器,所有上网请求经由代理服务器访问互联网资源

- 根据负载均衡策略将用户请求分发到不同服务器处理

简单来说,正向代理是客户端的代理,帮助客户端访问到无法直接获取的资源;反向代理是服务器的代理,帮助服务器平滑处理来自各方的请求。

程序设计中的代理

在程序设计中,也有一个代理模式,虽然和网络中的正向代理或反向代理的概念不完全一样,但本质上它们都是代理的概念,都是作为中介提供隔离、隐藏、控制访问和功能增强等作用。

Just show me the code! 现在我们用Go来编写一个代理的实例程序,假设我们有一个资源类,我们希望在访问这个资源时,记录访问次数,并在资源不再被引用时自动释放资源。

首先,定义一个资源接口Resource和实现这个接口的资源类MyResource:

package main

import (
    "fmt"
)

// Resource 接口定义了资源需要实现的方法
type Resource interface {
    Use()
    Release()
}

// MyResource 是实现了Resource接口的资源类
type MyResource struct{}

func (r *MyResource) Use() {
    fmt.Println("Using MyResource")
}

func (r *MyResource) Release() {
    fmt.Println("Releasing MyResource")
}

然后,定义一个代理的类 ResourceProxy,它包含了对资源的引用和引用计数,同时它也实现了Resource接口。

// ResourceProxy 是代理的结构体,包含资源和引用计数
type ResourceProxy struct {
    resource Resource
    refCount int
}

// NewResourceProxy 是ResourceProxy的构造函数
func NewResourceProxy(resource Resource) *ResourceProxy {
    return &ResourceProxy{resource: resource, refCount: 1} // 初始引用计数为1
}

// Use 方法增加引用计数并使用资源
func (sr *ResourceProxy) Use() {
    sr.refCount++
    fmt.Printf("Resource is used %d times\n", sr.refCount)
    sr.resource.Use()
}

// Release 方法减少引用计数,当计数为0时释放资源
func (sr *ResourceProxy) Release() {
    sr.refCount--
    if sr.refCount == 0 {
        sr.resource.Release()
    } else {
        fmt.Printf("Resource is still used by %d references\n", sr.refCount)
    }
}

最后我们使用这个代理:

func main() {
    resource := &MyResource{}
    proxyRef := NewResourceProxy(resource)

    proxyRef.Use() // 使用资源,引用计数增加
    proxyRef.Release() // 释放一次引用,引用计数减少到0,资源被释放

    // Output:
    // Resource is used 1 times
    // Using MyResource
    // Releasing MyResource
}

这个简单的例子演示了代理在资源管理中的应用,可以根据实际需要添加更多复杂的逻辑,比如错误处理、同步控制、日志记录等。

在程序设计中,代理模式是一种结构型设计模式,它让我们能提供一个替代品来代表另一个对象,这个替代品控制着对原对象的访问,可以在访问原对象前后进行一些额外处理。

通过上边的示例,我们可以发现代理模式的三个主要角色:

  • 抽象主题(Subject)
    :定义了代理和真实主题的共用接口,这样在任何使用真实主题的地方都可以使用代理。
  • 真实主题(Real Subject)
    :实现了抽象主题的具体类,代表了实际的对象,是最终要使用的对象。
  • 代理(Proxy)
    :包含对真实主题的引用,控制着对真实主题的访问,并可能负责创建和删除它。通常会做一些额外的事情来实现自己的价值。

在代码实际实现时,代理模式其实有多种不同的实现,包括:

  • 远程代理(Remote Proxy)
    :为一个对象在不同的地址空间(通常是不同计算机上的服务)提供局部代表。常见的如RPC、gRPC等,通过本地代理对象,客户端可以像调用本地接口一样访问远程服务,而无需关心网络通信的细节。
  • 虚拟代理(Virtual Proxy)
    :通过它来存放实例化需要很长时间的真实对象。常见的就是懒加载,比如加载一个大文件或者从数据库中读取大量数据,我们不希望在程序启动时就立刻加载,而是希望在真正需要这些数据的时候才去加载它们。
  • 保护代理(Protection Proxy)
    :控制对原始对象的访问。用于对象应该有不同访问权限的时候。
  • 智能引用(Smart Reference)
    :当对象被引用时,提供一些额外的操作,比如计算对象被引用的次数。上边提供的代码示例就是一个智能引用的例子。

这里就不展示更多的代码了,关键是在合适的时机使用恰当的代理模式来解决问题,这需要细细体会。

做个简单的小结,代理模式就像程序中的一个“中间人”,在不需要直接访问某个对象,或者直接访问某个对象不太方便或者不符合需求时,代理模式提供了一个非常灵活的解决方案。


正如本文所探讨的,代理模式在网络通信和程序设计中都扮演着重要的角色。
它通过提供一个中间层,增强了系统的安全性、灵活性和可维护性。掌握代理,我们就拥有了在合适的场景下解决问题的一种强大能力。希望本文的讨论能对你有一点用处。

近日,知名开源项目 Redis 宣布修改开源协议,从原来的「BSD 3-Clause 开源协议」改成「RSALv2 和 SSPLv1 双重许可证」。新的许可证主要是限制托管 Redis 产品的云服务商,比如 Redis 云数据库等,不能继续免费使用 Redis 最新的源代码。

我感觉这个改动对于大多数个人开发者来说影响不大,但有专业人士表示新的 RSALv2 和 SSPLv1 并不属于开源协议,所以网上一直有 Redis 不再开源之类的言论,在生存面前谁都有选择的权利,而且市面上兼容 Redis API 的开源内存数据库挺多的,后面我会逐一介绍。

说回上周的热门开源项目,霸榜一周的爬虫项目 MediaCrawler 的作者因担心不必要的麻烦删库了,那就用另外一款视频下载工具 lux 顶上。上期我分享了 FastAPI 的全栈项目模板,这期来个 Java 的 ruoyi-vue-pro 再次助力 Web 应用开发。话说,微软最近在 GitHub 上很活跃,除了最近开源的 Redis 替代品 Garnet,还有 K8s 网络可观测平台 retina 也不错。

最后,来一份前端面试 React 高频问题和答案,虽然之前的“金三银四”变成了现在的“苟住”,但还是祝愿大家都能找到自己喜欢、高薪的工作。

  • 本文目录
    • 1. 开源新闻
      • 1.1 Redis 的开源替代品
    • 2. 开源热搜项目
      • 2.1 Go 写的视频下载器:lux
      • 2.2 基于 Spring Boot 的全栈快开平台:ruoyi-vue-pro
      • 2.3 K8s 网络可观测平台:retina
      • 2.4 自建照片和视频管理平台:Immich
      • 2.5 计算机视觉 AI 工具库:supervision
    • 3. HelloGitHub 热评
      • 3.1 点亮日志文件的命令行工具
      • 3.2 前端面试 React 高频问题和答案
    • 4. 往期回顾

1. 开源新闻

1.1 Redis 的开源替代品

随着 Redis 修改开源协议,让使用 Redis 的云服务提供商、公司和个人开发者多了一些顾虑,担心 Redis 不再是长久之计。

所以,下面我将介绍 3 款可替代 Redis 的开源项目:Dragonfly、KeyDB、Garnet,它们都兼容 Redis API 可实现平滑替换。

Dragonfly

主语言:C++

Star:23k

它与当下最流行的两款内存数据库 Redis 和 Memcached 的 API 完全兼容,所以无需修改代码即可完成迁移。性能上更是爆炸,官方表示单实例可支持数百万量级的 QPS,而且吞吐量是 Redis 的 25 倍,并可以应对 TB 级别的内存数据集。

GitHub 地址:
https://github.com/dragonflydb/dragonfly

KeyDB

主语言:C++

Star:9.6k

该项目是由 Snapchat 开源的支持多线程的 Redis,它具有高性能、更高的吞吐量、完全兼容 Redis 协议等特点。有了多线程就可以放心大胆地执行 KEYS 和 SCAN 命令,不用再担心阻塞 Redis 了。

GitHub 地址:
https://github.com/Snapchat/KeyDB

Garnet

主语言:C#

Star:6.4k

这是由微软用 C# 开发的一款高性能分布式缓存系统,兼容各种编程语言的 Redis 客户端。性能方面相较于 Redis 具有更高的吞吐量、更少的成本和更低的延迟,支持 List、有序集合、HyperLogLog、Bitmap 等数据结构,以及集群模式、事务性存储过程、故障转移等功能。

GitHub 地址:
https://github.com/microsoft/garnet

2. 开源热搜项目

2.1 Go 写的视频下载器:lux

主语言:Go

Star:24k

周增长:570

这是一个国人用 Go 写的简单、快速的视频下载工具,它安装简单、使用方便,一条命令即可下载视频,支持国内外主流的视频网站。

GitHub 地址→
https://github.com/iawia002/lux

2.2 基于 Spring Boot 的全栈快开平台:ruoyi-vue-pro

主语言:Java

Star:23k

周增长:590

该项目是一个基于 Vue.js 和 Spring Boot 的前后端分离的快速开发平台,提供了精简和完整版,精简版仅包括系统功能、基础设施的功能,完整版则包含会员中心、数据报表、工作流程、商城系统、微信公众号、CRM 等功能,开发者可根据需求选择对应版本,快速起项目或进行二次开发。项目作者承诺该项目代码全部开源,永远不会有商业版!

GitHub 地址→
https://github.com/YunaiV/ruoyi-vue-pro

2.3 K8s 网络可观测平台:retina

主语言:Go

Star:1.9k

周增长:1.7k

这是由微软开源的基于 eBPF 的云原生容器网络可观测性平台,支持 Linux、Windows 等多种操作系统。它提供了一个集中查看、监控、分析应用和网络运行状况的中心平台,能够将收集的网络可观测性数据发送到 Prometheus 进行可视化,适用于调试 Pod 无法互连的问题、监控网络健康状况、收集遥测数据等场景。

GitHub 地址→
https://github.com/microsoft/retina

2.4 自建照片和视频管理平台:Immich

主语言:TypeScript

Star:28k

这是一个开源的自托管照片和视频的备份解决方案,当手机存储空间被照片和视频占满却又不舍得删,这个时候虽然有云存储服务但价格却让人望而却步,那就自己动手轻松搭建个人媒体库吧。该项目就是一个免费的备份手机里照片和视频的解决方案,提供了多客户端、自动备份、多用户、共享相册、实时查看照片等功能,既免费又安全还能保护个人隐私。

GitHub 地址→
https://github.com/immich-app/immich

2.5 计算机视觉 AI 工具库:supervision

主语言:Python

Star:12k

周增长:2k

该项目简化了对象检测、分类、标注、跟踪等计算机视觉的开发流程。开发者仅需加载数据集和模型,就能轻松实现对图像和视频进行检测、统计某区域的被检测数量等操作。

import cv2
import supervision as sv
from ultralytics import YOLO

image = cv2.imread(...)
model = YOLO('yolov8s.pt')
result = model(image)[0]
detections = sv.Detections.from_ultralytics(result)

len(detections)
# 5

GitHub 地址→
https://github.com/roboflow/supervision

3. HelloGitHub 热评

在这个章节,将会分享下本周 HelloGitHub 网站上的热门开源项目,欢迎与我们分享你上手这些开源项目后的使用体验。

3.1 点亮日志文件的命令行工具

主语言:Python

这是一个用于查看、追踪、合并和搜索,日志/JSON 长文件的命令行工具。它提供了高亮显示和实时追踪日志的功能,支持快速打开 GB 级的文件,并能根据时间戳自动合并日志文件。

项目详情→
https://hellogithub.com/repository/dd740029a8d54a0fa11dcd2fb0a226cb

3.2 前端面试 React 高频问题和答案

主语言:Other

该项目涵盖了一系列 React 相关的面试问题和答案,内容涉及基础知识、组件、状态管理、生命周期以及性能优化等方面。

项目详情→
https://hellogithub.com/repository/4d8c1c8eee0f4eb6ad79857563f1d6cb

3. 往期回顾

往期回顾:

以上为本周的「GitHub 热点速递」如果你发现其他好玩、实用的 GitHub 项目,就来
HelloGitHub
和大家一起分享下吧。

1.题目

给你两个单词
word1

word2
, 请返回将
word1
转换成
word2
所使
用的最少操作数

你可以对一个单词进行如下三种操作:

  • 删除一个字符
  • 替换一个字符
  • 插入一个字符

示例:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

链接:https://leetcode.cn/problems/edit-distance/description/

2.分析

可以转化为一道多维的动态规划问题,在
两个字符串的删除操作
的基础上添加了删除和替换操作。我们可以在二维的基础上额外添加一个变量来表示操作类型

k = 0,删除操作,k = 1替换操作,k = 2插入操作

1.确定dp数组

dp[i][j][k] 表示在word1 [0..i] ,word2 [0..j] 的子串执行k操作后满足两个字符串相等的最小操作数,k = 0,1,2

例如:

word1 = "h" , word2 = "r",dp[1][1][0] 表示执行删除操作后,word1 和 word2 相等的最小操作数,显然 dp[1][1][0] = 2

要记住 k 操作对应的是最后一个操作

在使用动态规划的时候,
在清晰 dp[i][j]考虑的是 i,j是末尾的状态的值,不要去考虑对别的值的影响,
例如  dp[k][p] (k ≥ i 和  p≥ j的情况)

2.确定转换公式

转换可以分为两个情况, word1[i] == word2[j] 和  word1[i] ≠ word2[j]

1.word1[i] == word2[j]

如果 word1[i] == word2[j]了,那么我们其实不需要进行任何操作,此时取前一个状态的最小值就好

int tmp = min(min(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1]), dp[i - 1][j - 1][2]);
dp[i][j][0] = tmp;
dp[i][j][1] = tmp;
dp[i][j][2] = tmp;

2.word1[i] ≠ word2[j]

word1[i] ≠ word2[j]时,可以拆分为插入,删除和替换三种情况

2.1 删除

删除操作对应dp[i][j]时删除 i 或者 删除 j,那么只要考虑 dp[i-1][j],dp[i][j-1]的情况即可(注意这里 dp[i-1][j],dp[i][j-1] 都可能进行多种操作)

int tmp2 = min(min(dp[i - 1][j][1], dp[i - 1][j][2]), dp[i - 1][j][0]);
int tmp3 = min(min(dp[i][j-1][1], dp[i][j-1][2]), dp[i][j-1][0]);
dp[i][j][0] = min(tmp2, tmp3) + 1;

2.2 替换

对于[i][j]进行替换,那么我们只需要替换 i 或者 替换 j 就可以了,替换就是在 [0..i-1] [0..j-1]的基础上,加上一个操作使得 i == j

    int tmp1 = min(min(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1]), dp[i - 1][j - 1][2]);
    dp[i][j][1] = tmp1 + 1;

2.3 插入

对于插入操作,本质和删除一致的,为什么这么说呢?

word1[i] ≠ word2[j],我们只能在 i 或者 j 的尾部进行插入,即 i-1 插入字符 char 使得 char  == word2[j];或者 j-1 位置插入字符  char 使得 char  == word1[i]

如果在 i 或者 j后面插入,
我们还需要额外进行一次删除操作,
因此插入操作代码和删除一致,这里可以进行优化

int tmp2 = min(min(dp[i - 1][j][1], dp[i - 1][j][2]), dp[i - 1][j][0]);
int tmp3 = min(min(dp[i][j-1][1], dp[i][j-1][2]), dp[i][j-1][0]);
dp[i][j][2] = min(tmp2, tmp3) + 1;

3. 初始化

考虑到dp会用到前面的数据,便于递推额外添加一个大小,因此 dp初始化为  vector<vector<vector<int>>> dp(word1.size() + 1, vector<vector<int>>(word2.size()+ 1, vector<int>(3)));

word1取[0..0]的时候,word1为空字符串“”;word2只能删除全部字符,或者word2替换全部字符为 " " 空字符串,或者word1插入和word2一样的字符

同理word2取[0..0]的时候也是

  vector<vector<vector<int>>> dp(M + 1, vector<vector<int>>(N + 1, vector<int>(3)));
  //每一步都可能执行不同的操作 这时候替换表示地替换成  "" 空字符串,插入表示对另一边进行插入
  for (int i = 0; i <= M; ++i)
  {
      dp[i][0][0] = i;
      dp[i][0][1] = i;
      dp[i][0][2] = i;
  }
  for (int j = 0; j <= N; ++j)
  {
      dp[0][j][0] = j;
      dp[0][j][1] = j;
      dp[0][j][2] = j;
  }

3. 代码实现

class Solution {
public:
    int minDistance(string word1, string word2) {
        //dp[i][j][k] 表示 [0..i] [0..j]相同所需的最少操作符 k表示执行这个操作时最小值
        //最后只需要对三个数 求最小值
        const int M = word1.size();
        const int N = word2.size();
        vector<vector<vector<int>>> dp(M + 1, vector<vector<int>>(N + 1, vector<int>(3)));
        //每一步都可能执行不同的操作 这时候替换表示地替换成  "" 空字符串,插入表示对另一边进行插入
        // 0-插入 1 -删除 2-替换
        for (int i = 0; i <= M; ++i)
        {
            dp[i][0][0] = i;
            dp[i][0][1] = i;
            dp[i][0][2] = i;
        }
        for (int j = 0; j <= N; ++j)
        {
            dp[0][j][0] = j;
            dp[0][j][1] = j;
            dp[0][j][2] = j;
        }
        for (int i = 1; i <= M; ++i)
        {
            for (int j = 1; j <= N; ++j)
            {
                if (word1[i-1] == word2[j-1])
                {
                    int tmp = min(min(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1]), dp[i - 1][j - 1][2]);
                    dp[i][j][0] = tmp;
                    dp[i][j][1] = tmp;
                    dp[i][j][2] = tmp;
                }
                else
                {
                    int tmp2 = min(min(dp[i - 1][j][1], dp[i - 1][j][2]), dp[i - 1][j][0]);
                    int tmp3 = min(min(dp[i][j-1][1], dp[i][j-1][2]), dp[i][j-1][0]);
                    dp[i][j][0] = min(tmp2, tmp3) + 1;
                    //替换 在i-1,j-1的基础上,替换值
                    int tmp1 = min(min(dp[i - 1][j - 1][0], dp[i - 1][j - 1][1]), dp[i - 1][j - 1][2]);
                    dp[i][j][1] = tmp1 + 1;
                    //插入 - 最优情况只能在少的一边插入,否则会增加一个删除操作
                    //删除和插入本质应该一致,因为都应该在尾部插,否则增加额外一个删除操作
                    dp[i][j][2] = dp[i][j][0];
                }
            }
        }
        return min(min(dp[M][N][0], dp[M][N][1]), dp[M][N][2]);
    }
};

4.优化

上面分成三个状态推导为了方便理解,优化情况下不需要同时考虑三个操作,只需要考虑 dp[i][j]的变化即可(其实就是对操作进行合并)

class Solution {
public:
    int minDistance(string word1, string word2) {
        //优化
        const int M = word1.size();
        const int N = word2.size();
        vector<vector<int>>dp(M + 1, vector<int>(N + 1));
        for (int i = 0; i <= M; ++i)
            dp[i][0] = i;
        for (int j = 0; j <= N; ++j)
            dp[0][j] = j;

        for (int i = 1; i <= M; ++i)
        {
            for (int j = 1; j <= N; ++j)
            {
                if (word1[i - 1] == word2[j - 1])
                {
                    dp[i][j] = dp[i - 1][j - 1]; //不进行任何操作
                }
                else
                {
                    dp[i][j] = min({ dp[i - 1][j],dp[i][j - 1],dp[i - 1][j - 1] }) + 1;
                }
            }
        }
        return dp[M][N];
    }
};

前言

我们每天都在用
v-model
,并且大家都知道在vue3中
v-model

:modelValue

@update:modelValue
的语法糖。那你知道
v-model
指令是如何变成组件上的
modelValue
属性和
@update:modelValue
事件呢?将
v-model
指令转换为
modelValue
属性和
@update:modelValue
事件这一过程是在编译时还是运行时进行的呢?

先说结论

下面这个是我画的处理
v-model
指令的完整流程图:
vModel-progress

首先会调用
parse
函数将template模块中的代码转换为AST抽象语法树,此时使用
v-model
的node节点的props属性中还是
v-model
。接着会调用
transform
函数,经过
transform
函数处理后在
node
节点中多了一个
codegenNode
属性。在
codegenNode
属性中我们看到没有
v-model
指令,取而代之的是
modelValue

onUpdate:modelValue
属性。经过
transform
函数处理后已经将
v-model
指令编译为
modelValue

onUpdate:modelValue
属性,此时还是AST抽象语法树。所以接下来就是调用
generate
函数将AST抽象语法树转换为
render
函数,到此为止编译时做的事情已经做完了,
经过编译时的处理
v-model
指令已经变成了
modelValue

onUpdate:modelValue
属性。

接着就是运行时阶段,在浏览器中执行
render
函数生成虚拟DOM。在生成虚拟DOM的过程中由于props属性中有
modelValue

onUpdate:modelValue
属性,所以就会给组件对象加上
modelValue
属性和
@update:modelValue
事件。最后就是调用
mount
方法将虚拟DOM转换为真实DOM。
所以
v-model
指令转换为
modelValue
属性和
@update:modelValue
事件这一过程是在编译时进行的。

什么是编译时?什么是运行时?

vue是一个编译时+运行时一起工作的框架,之前有小伙伴私信我说自己傻傻分不清楚在vue中什么时候是编译时,什么时候是运行时。要回答小伙伴的这个问题我们要从一个vue文件是如何渲染到浏览器窗口中说起。

我们的vue代码一般都是写在后缀名为vue的文件上,显然浏览器是不认识vue文件的,浏览器只认识html、css、jss等文件类型。所以第一步就是通过webpack或者vite将一个vue文件编译为一个包含
render
函数的js文件,在这一步中代码的执行环境是在nodejs中进行,也就是我们所说的编译时。相比浏览器端来说能够拿到的权限更多,也能做更多的事情。后面就是执行
render
函数生成虚拟DOM,再调用浏览器的DOM API根据虚拟DOM生成真实DOM挂载到浏览器上。在第一步后面的这些过程中代码执行环境都是在浏览器中,也就是我们所说的运行时。在客户端渲染的场景下,一句话总结就是:代码跑在nodejs端的时候就是编译时,代码跑在浏览器端的时候就是运行时。
full-progress

举个例子

我们来看一个
v-model
的例子,父组件
index.vue
的代码如下:

<template>
  <CommonChild v-model="inputValue" />
  <p>input value is: {{ inputValue }}</p>
</template>

<script setup lang="ts">
import { ref } from "vue";
import CommonChild from "./child.vue";

const inputValue = ref();
</script>

我们上面是一个很简单的
v-model
的例子,在
CommonChild
子组件上使用
v-model
绑定一个叫
inputValue
的ref变量,然后将这个
inputValue
变量渲染到p标签上面。

前面我们已经讲过了客户端渲染的场景下,在nodejs端工作的时候是编译时,在浏览器端工作的时候是运行时。那我们现在先来看看经过
编译时
阶段处理后,刚刚进入到浏览器端
运行时
阶段的js代码是什么样的。我们要如何在浏览器中找到这个js文件呢?其实很简单直接在network上面找到你的那个vue文件就行了,比如我这里的文件是
index.vue
,那我只需要在network上面找叫
index.vue
的文件就行了。但是需要注意一下network上面有两个
index.vue
的js请求,分别是template模块+script模块编译后的js文件,和style模块编译后的js文件。

那怎么区分这两个
index.vue
文件呢?很简单,通过query就可以区分。由style模块编译后的js文件的URL中有type=style的query,如下图所示:
network

这时有的小伙伴就开始疑惑了不是说好的浏览器不认识vue文件吗?怎么这里的文件名称是
index.vue
而不是
index.js
呢?其实很简单,在开发环境时
index.vue
文件是在
App.vue
文件中import导入的,而
App.vue
文件是在
main.js
文件中import导入的。所以当浏览器中执行
main.js
的代码时发现import导入了
App.vue
文件,那浏览器就会去加载
App.vue
文件。当浏览器加载完
App.vue
文件后执行时发现import导入了
index.vue
文件,所以浏览器就会去加载
index.vue
文件,而不是
index.js
文件。

至于什么时候将
index.vue
文件中的template模块、script模块、style模块编译成js代码,我们在
通过debug搞清楚.vue文件怎么变成.js文件
文章中已经讲过了当import加载一个文件时会触发
@vitejs/plugin-vue
包中的
transform
钩子函数,在这个
transform
钩子函数中会将template模块、script模块、style模块编译成js代码。所以在浏览器中拿到的index.vue文件就是经过编译后的js代码了。

现在我们在浏览器的network中来看刚刚进入编译时
index.vue
文件代码,简化后的代码如下:

import {
  Fragment as _Fragment,
  createElementBlock as _createElementBlock,
  createElementVNode as _createElementVNode,
  createVNode as _createVNode,
  defineComponent as _defineComponent,
  openBlock as _openBlock,
  toDisplayString as _toDisplayString,
  ref,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
import CommonChild from "/src/components/vModel/child.vue?t=1710943659056";
import "/src/components/vModel/index.vue?vue&type=style&index=0&scoped=0ebe7d62&lang.css";

const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    __expose();
    const inputValue = ref();
    const __returned__ = { inputValue, CommonChild };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      _Fragment,
      null,
      [
        _createVNode(
          $setup["CommonChild"],
          {
            modelValue: $setup.inputValue,
            "onUpdate:modelValue":
              _cache[0] ||
              (_cache[0] = ($event) => ($setup.inputValue = $event)),
          },
          null,
          8,
          ["modelValue"]
        ),
        _createElementVNode(
          "p",
          null,
          "input value is: " + _toDisplayString($setup.inputValue),
          1
          /* TEXT */
        ),
      ],
      64
      /* STABLE_FRAGMENT */
    )
  );
}

_sfc_main.render = _sfc_render;
export default _sfc_main;

从上面的代码中我们可以看到编译后的js代码主要分为两块,第一块是
_sfc_main
组件对象,里面有name属性和setup方法。一个vue组件在运行时实际就是一个对象,这里的
_sfc_main
就是一个vue组件对象。至于
defineComponent
函数的作用是在定义 Vue 组件时提供类型推导的辅助函数,所以在我们这个场景没什么用。我们接着来看第二块
_sfc_render
,从名字我想你应该已经猜到了他是一个render函数。执行这个
_sfc_render
函数就会生成虚拟DOM,然后再由虚拟DOM生成浏览器上面的真实DOM。

我们再来看这个
render
函数,在这个
render
函数前面会调用
openBlock
函数和
createElementBlock
函数。他的作用是在编译时尽可能的提取多的关键信息,可以减少运行时比较新旧虚拟DOM带来的性能开销,我们这篇文章不关注这点,所以我们接下来会直接看下面的
_createVNode
函数和
_createElementVNode
函数。

v-model
语法糖怎么工作的

我们接着来看
render
函数中的
_createVNode
函数和
_createElementVNode
函数,代码如下:

import {
  createElementVNode as _createElementVNode,
  createVNode as _createVNode,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";

_createVNode(
  $setup["CommonChild"],
  {
    modelValue: $setup.inputValue,
    "onUpdate:modelValue":
      _cache[0] ||
      (_cache[0] = ($event) => ($setup.inputValue = $event)),
  },
  null,
  8,
  ["modelValue"]
),
_createElementVNode(
  "p",
  null,
  "input value is: " + _toDisplayString($setup.inputValue),
  1
  /* TEXT */
),

从这两个函数的名字我想你也能猜出来他们的作用是创建虚拟DOM,再仔细一看这两个函数不就是对应的我们template模块中的这两行代码吗。

<CommonChild v-model="inputValue" />
<p>input value is: {{ inputValue }}</p>

第一个
_createVNode
函数对应的是
CommonChild
,第二个
_createElementVNode
对应的是
p
标签。我们将重点放在
_createVNode
函数上,从import导入来看
_createVNode
函数是从vue中导出的
createVNode
函数。你是不是觉得
createVNode
这个名字比较熟悉呢,其实在
vue官网
中有提到。

h()

hyperscript
的简称——意思是“能生成 HTML (超文本标记语言) 的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是
createVnode()
,但当你需要多次使用渲染函数时,一个简短的名字会更省力。

vue官网中
h()
函数用于生成虚拟DOM,其实
h()函数
底层就是调用的
createVnode
函数。同样的
createVnode
函数和
h()
函数接收的参数也差不多,第一个参数可以是一个组件对象也可以是像
p
这样的html标签,也可以是一个虚拟DOM。第二个参数为给组件或者html标签传递的props属性或者attribute。第三个参数是该节点的children子节点。现在我们再来仔细看这个
_createVNode
函数你应该已经明白了:

_createVNode(
  $setup["CommonChild"],
  {
    modelValue: $setup.inputValue,
    "onUpdate:modelValue":
      _cache[0] ||
      (_cache[0] = ($event) => ($setup.inputValue = $event)),
  },
  null,
  8,
  ["modelValue"]
),

我们在
Vue 3 的 setup语法糖到底是什么东西?
文章中已经讲过了
render
函数中的
$setup
变量就是
setup
函数的返回值经过
Proxy
处理后的对象,由于
Proxy
的拦截处理让我们在template中使用ref变量时无需再写
.value
。在上面的
setup
函数中我们看到
CommonChild
组件对象也在返回值对象中,所以这里传入给
createVNode
函数的第一个参数为
CommonChild
组件对象。

我们再来看第二个参数对象,对象中有两个key,分别是
modelValue

onUpdate:modelValue
。这两个key就是传递给
CommonChild
组件的两个props,等等这里有两个问题。第一个问题是这里怎么是
onUpdate:modelValue
,我们知道的
v-model

:modelValue

@update:modelValue
的语法糖,不是说好的
@update
怎么变成了
onUpdate
了呢?第二个问题是
onUpdate:modelValue
明显是事件监听而不是props属性,怎么是“通过props属性”而不是“通过事件”传递给了
CommonChild
子组件呢?

因为在编译时处理v-on事件监听会将监听的事件首字母变成大写然后在前面加一个
on
,塞到props属性对象中,所以这里才是
onUpdate:modelValue
。所以在组件上不管是v-bind的attribute和prop,还是v-on事件监听,经过编译后都会被塞到一个大的props对象中。以
on
开头的属性我们都视作事件监听,用于和普通的attribute和prop区分。所以你在组件上绑定一个
onConfirm
属性,属性值为一个
handleClick
的函数。在子组件中使用
emit('confirm')
是可以触发
handleClick
函数的执行的,但是一般情况下还是不要这样写,维护代码的人会看着一脸蒙蔽的。

我们接着来看传递给
CommonChild
组件的这两个属性值。

{
  modelValue: $setup.inputValue,
  "onUpdate:modelValue":
    _cache[0] ||
    (_cache[0] = ($event) => ($setup.inputValue = $event)),
}

第一个
modelValue
的属性值是
$setup.inputValue
。前面我们已经讲过了
$setup.inputValue
就是指向
setup
中定义的名为
inputValue
的ref变量,所以第一个属性的作用就是给
CommonChild
组件添加
:modelValue="inputValue"
的属性。

我们再来看第二个属性
onUpdate:modelValue
,属性值为
_cache[0] ||(_cache[0] = ($event) => ($setup.inputValue = $event))
。这里为什么要加一个
_cache
缓存呢?原因是每次页面刷新都会重新触发
render
函数的执行,如果不加缓存那不就变成了每次执行
render
函数都会生成一个事件处理函数。这里的事件处理函数也很简单,接收一个
$event
变量然后赋值给
setup
中的
inputValue
变量。接收的
$event
变量就是我们在子组件中调用
emit
触发事件传过来的第二个变量,比如:
emit('update:modelValue', 'helllo word')
。为什么是第二个变量呢?是因为
emit
函数接收的第一个变量为要触发的事件名称。所以第二个属性的作用就是给
CommonChild
组件添加
@update:modelValue
的事件绑定。

编译时如何处理v-model

前面我们已经讲过了在运行时已经拿到了key为
modelValue

onUpdate:modelValue
的props属性对象了,我们知道这个
props
属性对象是在编译时由
v-model
指令编译而来的,那在这个编译过程中是如何处理
v-model
指令的呢?请看下面编译时的流程图:

compile-progress

首先会调用
parse
函数将template模块中的代码转换为AST抽象语法树,此时使用
v-model
的node节点的props属性中还是
v-model
。接着会调用
transform
函数,经过
transform
函数处理后在
node
节点中多了一个
codegenNode
属性。在
codegenNode
属性中我们看到没有
v-model
指令,取而代之的是
modelValue

onUpdate:modelValue
属性。经过
transform
函数处理后已经将
v-model
指令编译为
modelValue

onUpdate:modelValue
属性,此时还是AST抽象语法树。所以接下来就是调用
generate
函数将AST抽象语法树转换为
render
函数,到此为止编译时做的事情已经做完了。

parse
函数

首先是使用
parse
函数将template模块中的代码编译成AST抽象语法树,在这个过程中会使用到大量的正则表达式对字符串进行解析。我们直接来看编译后的AST抽象语法树是什么样子:
parser

从上图中我们可以看到使用
v-model
指令的node节点中有了
name

model

rawName

v-model
的props了,明显可以看出将template中code代码字符串转换为AST抽象语法树时没有处理
v-model
指令。那么什么时候处理的
v-model
指令呢?

transform
函数

其实是在后面的一个
transform
函数中处理的,在这个函数中主要调用的是
traverseNode
函数处理AST抽象语法树。在
traverseNode
函数中会去递归的去处理AST抽象语法树中的所有node节点,这也解释了为什么还要在
transform
函数中再抽取出来一个
traverseNode
函数。

我们再来思考一个问题,由于
traverseNode
函数会处理node节点的所有情况,比如
v-model
指令、
v-for
指令、
v-on

v-bind
。如果将这些的逻辑全部都放到
traverseNode
函数中,那
traverseNode
函数的体量将会是非常大的。所以抽取出来一个
nodeTransforms
的概念,这个
nodeTransforms
是一个数组。里面存了一组
transform
函数,用于处理node节点。每个
transform
函数都有自己独有的作用,比如
transformModel
函数用于处理
v-model
指令,
transformIf
函数用于处理
v-if
指令。我们来看看经过
transform
函数处理后的AST抽象语法树是什么样的:
transform

从上图中我们可以看到同一个使用
v-model
指令的node节点,经过
transform
函数处理后的和第一步经过
parse
函数处理后比起来node节点最外层多了一个
codegenNode
属性。

我们接下来看看
codegenNode
属性里面是什么样的:
prop1

从上图中我们可以看到在
codegenNode
中还有一个
props
属性,在
props
属性下面还有一个
properties
属性。这个
properties
属性是一个数组,里面就是存的是node节点经过transform函数处理后的props属性的内容。我们看到
properties
数组中的每一个item都有
key

value
属性,我想你应该已经反应过来了,这个
key

value
分别对应的是props属性中的属性名和属性值。从上图中我们看到第一个属性的属性名
key
的值为
modelValue
,属性值
value

$setup.inputValue
。这个刚好就对应上
v-model
指令编译后的
:modelValue="$setup.inputValue"

我们再来接着看第二个属性:
prop2

从上图中我们同样也可以看到第二个属性的属性名
key
的值为
onUpdate:modelValue
,属性值
value
的值拼起来就是为一串箭头函数,和我们前面编译后的代码一模一样。第二个属性刚好就对应上
v-model
指令编译后的
@update:modelValue="($event) => ($setup.inputValue = $event)"

从上面的分析我们看到经过
transform
函数的处理后已经将
v-model
指令处理为对应的代码了,接下来我们要做的事情就是调用
generate
函数将AST抽象语法树转换成
render
函数

generate
函数


generate
函数中会递归遍历AST抽象语法树,然后生成对应的浏览器可执行的js代码。如下图:
generate

从上图中我们可以看到经过
generate
函数处理后生成的
render
函数和我们之前在浏览器的network中看到的经过编译后的
index.vue
文件中的
render
函数一模一样。这也证明了
modelValue
属性和
@update:modelValue
事件塞到组件上是在编译时进行的。

总结

现在我们可以回答前面提的两个问题了:

  • v-model
    指令是如何变成组件上的
    modelValue
    属性和
    @update:modelValue
    事件呢?

    首先会调用
    parse
    函数将template模块中的代码转换为AST抽象语法树,此时使用
    v-model
    的node节点的props属性中还是
    v-model
    。接着会调用
    transform
    函数,经过
    transform
    函数处理后在
    node
    节点中多了一个
    codegenNode
    属性。在
    codegenNode
    属性中我们看到没有
    v-model
    指令,取而代之的是
    modelValue

    onUpdate:modelValue
    属性。经过
    transform
    函数处理后已经将
    v-model
    指令编译为
    modelValue

    onUpdate:modelValue
    属性。其实在运行时
    onUpdate:modelValue
    属性就是等同于
    @update:modelValue
    事件。接着就是调用
    generate
    函数,将AST抽象语法树生成
    render
    函数。然后在浏览器中执行
    render
    函数时,将拿到的
    modelValue

    onUpdate:modelValue
    属性塞到组件对象上,所以在组件上就多了两个
    modelValue
    属性和
    @update:modelValue
    事件。


  • v-model
    指令转换为
    modelValue
    属性和
    @update:modelValue
    事件这一过程是在编译时还是运行时进行的呢?

    从上面的问题答案中我们可以知道将
    v-model
    指令转换为
    modelValue
    属性和
    @update:modelValue
    事件这一过程是在编译时进行的。


transform
函数中是调用
transformModel
函数处理
v-model
指令,这篇文章没有深入到
transformModel
函数源码内去讲解。如果大家对
transformModel
函数的源码感兴趣请在评论区留言或者给我发信息,我会在后面的文章安排上。

关注公众号:
前端欧阳
,解锁我更多
vue
干货文章。还可以加我微信,私信我想看哪些
vue
原理文章,我会根据大家的反馈进行创作。
qrcode
wxcode