2024年3月

前言

写这篇东西,是因为官方文档看着太痛苦,于是乎想用大白话来聊聊 ElasticSearc (下面都简称ES)。所以下文对于 ES 一些概念的表述可能会与官方有出入,所以需要准确的表述和详细定义的,请跳转官方文档。我也尽量贴上官方的链接。

前置知识:因为下文会使用 mysql 的一些概念来描述,所以没学过的不建议看文本。

本文基于 ES 8.12

ES 基础概念

ES 是一个
搜索
引擎。重点是大数据搜索,如果是传统的 CRUD,请考虑关系型数据库。

下面来了解几个 ES 的基础概念

索引(index)

Index modules | Elasticsearch Guide [8.12] | Elastic

类比 mysql 的表,但又与表有很大区别

mysql:创建表需要先定义表字段,再进行数据插入。并且如果添加新字段,需要先修改表结构,再进行数据插入。

ES:索引无需预先定义字段(ES 里面叫映射),可在插入数据时动态添加字段(这叫动态映射,后面讲)

文档类型(type)

ES7 开始,文档类型只有默认的
_doc
了,我没用过旧版,所以略...

文档(document)

类比 mysql 的表的一条数据。

映射(mapping)

Mapping | Elasticsearch Guide [8.12] | Elastic

类比 mysql 的表字段,不同类型的字段,存储和查找会有区别

查看索引的映射

-- 查看映射(索引名为 my-index 的映射)
GET /my-index/_mappings
-- 查看映射的某个字段(索引名为 my-index 的字段 age 的映射)
GET /my-index/_mapping/field/age

动态映射

Dynamic field mapping | Elasticsearch Guide [8.12] | Elastic

ES 与传统关系型数据库不同,创建索引(类比 mysql 的表)时,不需要指定字段,在插入数据时,会自动创建字段和判断字段的类型,这就是动态映射。

动态映射默认的开启的,可以通过 dynamic 参数来修改。
dynamic | Elasticsearch Guide [8.12] | Elastic

以下是不同 dynamic 参数对 json 数据类型的映射

JSON 数据类型 dynamic: true dynamic: runtime
null No field added No field added
true or false boolean boolean
double float float
long long long
object object No field added
array 取决于数组中的第一个非 null 值 取决于数组中的第一个非 null 值
日期类型的 string date date
数字类型的 string float or long double or long
其他类型的 string text with a .keyword sub-field keyword

注意:默认的数字检测是关闭的,也就是在 没有映射 的情况下,插入 666 字符串是识别的成字符串的。如果已经配置了映射字段是 long 类型,插入 666 字符串才会被识别为数字

日期检测:
默认的日期检测格式有
yyyy/MM/dd HH:mm:ss||yyyy/MM/dd
也就是 2024/01/01 12:00:00 或者 2024/01/01 ,但是 2024-01-01 格式也行, 2024-01-01 12:00:00 不可以

自定义日期检测

PUT my-index
{
  "mappings": {
    "dynamic_date_formats": [ "yyyy/MM", "MM/dd/yyyy"]
    // 或者用
    "dynamic_date_formats": [ "yyyy/MM|MM/dd/yyyy"]
  }
}

两者的区别在于,
[ "yyyy/MM", "MM/dd/yyyy"]
第一次插入数据,匹配到哪种,以后都用这种检测。而
[ "yyyy/MM|MM/dd/yyyy"]
则是两种格式都能用

显式映射

创建索引时添加映射

不需要搜索的字段,建议用 index: false 属性
index | Elasticsearch Guide [8.12] | Elastic

PUT /my-index
{
  "mappings": {
    "properties": {
      "age":    { "type": "integer" },  
      "email":  { "type": "keyword" , "index": false }, 
      "name":   { "type": "text"  }     
    }
  }
}

向现有映射添加字段

PUT /my-index/_mapping
{
  "properties": {
    "employee-id": {
      "type": "keyword",
      "index": false
    }
  }
}

修改现有映射

除了支持的
映射参数
外,您不能更改现有字段的映射或字段类型。更改现有字段可能会使已索引的数据无效。

如果需要更改字段的映射,请使用正确的映射创建一个新索引,并将数据
重新索引
到该索引中。

说人话就是:ES 是用来搜索的,官方不建议修改字段映射。不比关系数据库,ES 更改字段的映射很麻烦,跟重新建一个索引把数据导进去差不多(官方也建议这么玩)

总结

本文用大白话讲了 ES 的几个基本概念和映射的操作,旨在让小白能快速了解 ES,如果文章有错误的地方,欢迎评论区指出。

参考资料

Elasticsearch Mapping类型修改 - 知乎 (zhihu.com)

clickhouse目前用在实时BI后台,只要数据稳定落库了,出报表很快,临时查询也很快,在使用过程中,对它的一些优点和不足也是深有体会,这里总结一下,不能做到面面俱到,但尽可能详细的介绍实际应用需要注意的问题和应用技巧。

我们是通过编写Flink程序,消费kafka数据,将数据清洗,扩充维度,然后落在clickhouse里面,半年以来,Flink程序很少出问题,数据落库也很稳定。对于clickhouse,使用的是腾讯云的clickhouse服务,有副本的集群,中间扩充了几次磁盘,服务也是挺稳定的,整体看来,整个BI后台,都能稳定的提供数据报表。为了书写方便,接下来clickhouse用ck缩写。

ck里面引用mysql外部数据表

通常需要在ck里面要用mysql里面的表,比如mysql里面存在一张维表,我们需要根据id查询出某个名称,这个时候,不需要把数据导一份过来,就可以把mysql表映射到ck里面,或者直接整个mysql数据库映射到ck某个库里面,就能操作mysql这个数据库所有表,使用sql语法关联查询mysql和ck的表。

MySQL引擎用于将远程的MySQL服务器中的表映射到ClickHouse中,并允许您对表进行INSERT和SELECT查询,以方便您在ClickHouse与MySQL之间进行数据交换。

创建数据库

CREATE DATABASE [IF NOT EXISTS] db_name [ON CLUSTER cluster]
ENGINE = MySQL('host:port', ['database' | database], 'user', 'password')

比如,我们在mysql里面创建一张表:

mysql> USE test;
Database changed

mysql> CREATE TABLE `mysql_table` (
    ->   `int_id` INT NOT NULL AUTO_INCREMENT,
    ->   `float` FLOAT NOT NULL,
    ->   PRIMARY KEY (`int_id`));
Query OK, 0 rows affected (0,09 sec)

mysql> insert into mysql_table (`int_id`, `float`) VALUES (1,2);
Query OK, 1 row affected (0,00 sec)

mysql> select * from mysql_table;
+------+-----+
| int_id | value |
+------+-----+
|      1 |     2 |
+------+-----+
1 row in set (0,00 sec)

我们去ck里面创建一个数据库,跟mysql这个数据库关联起来。

CREATE DATABASE mysql_db ENGINE = MySQL('localhost:3306', 'test', 'my_user', 'user_password')

这样就在ck里面创建了一个mysql_db,这个数据库跟mysql的test数据库是映射在一起了,我们在ck里面直接查询:

SELECT * FROM mysql_db.mysql_table

┌─int_id─┬─value─┐
│      1 │     2 │
└────────┴───────┘

数据库引擎可以是mysql,也可以是其它数据库,比如sqlite、PostgreSQL,更多可以查阅官方文档:

https://clickhouse.com/docs/zh/engines/database-engines

ck带副本的分布式表

带副本的分布式表,就是分布式表,并且单个part也是有副本的,刚开始我们建表时候,也是花了一些时间,回忆下当时的问题主要有以下:

1) 带副本的分布式表的创建问题,怎么创建?

开始我们也是创建错了,发现数据不完整,每次只有一半,后来得知腾讯云的服务是带副本的分布式集群,创建表也需要带副本的分布式表,不然数据有丢失,建表分2步,语句如下:

-- 第一步:创建本地表,这个表会在每个机器节点上面创建,不要漏了on cluster cluster_name
CREATE TABLE test.table_local on cluster default_cluster
(
    `id` Int32,
    `uid` String,
    `name` String,
    `time` Int32,
    `create_at` DateTime
)
ENGINE = ReplicatedMergeTree()
PARTITION BY toYYYYMM(create_at)
ORDER BY id;

-- 第二步:创建分布式表
CREATE TABLE test.table_all on cluster default_cluster as test.table_local 
ENGINE = Distributed('default_cluster', 'test', 'table_local', sipHash64(id));

参数说明:

ReplicatedMergeTree:带副本的表引擎;

PARTITION BY:数据分区方式;

sipHash64(id):分布式表在每个节点上面的数据分发方式;

具体可以看官方文档,地址:

https://clickhouse.com/docs/zh/engines/table-engines/mergetree-family/replication

后文,都已这张表为例。

2)分布式表,插入数据要每个节点都执行插入操作吗?

不需要,用标准sql语法插入分布式表即可,比如:

insert into test.table_all values(.....)

3)分布式表的更新删除操作,与mysql相同吗?

不相同,只能说是相似,按照模板来使用即可,alter table语法:

ALTER TABLE [db.]table UPDATE column1 = expr1 [, ...] WHERE filter_expr

比如:

alter table test.table_local on cluster default_cluster update name = 'name' where id = 10000

注意:更新操作,需要用本地表test.table_local,不能用分布式表。

删除操作也是一样的:

alter table test.table_local on cluster default_cluster delete where id = 10000

4)分布式表,添加列,修改列的类型

-- 添加列
ALTER TABLE test.table_local ON CLUSTER default_cluster ADD COLUMN product String;

-- 修改列
ALTER TABLE test.table_local on cluster default_cluster MODIFY COLUMN uid Int64;

可以看到,ck带副本的表与标准sql语法的区别在于使用了alter table和on cluster关键字,使用时候,套用模板即可。其它的一些DDL操作可以看具体官方文档:

https://clickhouse.com/docs/zh/sql-reference/statements/alter

写性能

ck提倡低频、大批量写,每秒钟只写几次,每次插入上万、十万条数据,这是比较合适的。因为如果稍微了解一下底层原理就知道,ck会间隔合并数据块,不宜频繁写入导致频繁合并,影响性能。

在使用Flink导入数据的过程中,需要攒数据,批量写,我们通过Flink窗口函数积累数据,每次写5秒钟的一批数据。记得刚开始使用ck的时候,开发没注意这些,运维就说要批量写,后来基本就统一了。

添加索引需要注意

ck里面有一级稀疏索引,和二级跳数索引,二级索引是基于一级索引的,有时候一张表建完了,写入数据,我们发现查询需要用到一些字段,需要加索引,语句:

-- 添加索引
alter table test.table_local on cluster default_cluster add index index_uid uid type minmax(100) GRANULARITY 2;

-- 使索引生效,对历史数据也生效索引
ALTER TABLE test.table_local MATERIALIZE index index_uid;

也是用的alter table格式,这里需要注意的是,索引是在插入数据时候建立的,新建索引对历史数据是不生效的,需要让历史数据也生效。

数据去重

ReplacingMergeTree引擎表会删除排序键值相同的重复项,排序键值就是建表时候跟在order by后面的字段。ck对更新不友好,性能很差,于是可以利用这个引擎,每次只管写入,不需要更新,ck会自动帮我们保存最新版本。建表语句如下:

CREATE TABLE test.test_local on cluster default_cluster (
    `id` UInt64,
    `type` Int32,
    `username` String,
    `password` String,
    `phone` String COMMENT '手机号账户',
    `nick` String,
    `mobile` String,
    `insert_time` DateTime DEFAULT '2023-07-31 00:00:00'
) ENGINE = ReplicatedReplacingMergeTree()
partition by dt
order by id;

CREATE TABLE test.test_all on cluster default_cluster as test.test_local ENGINE = Distributed('default_cluster', 'test', 'test_local', sipHash64(id));

insert_time字段需要有,放在最后,便于ck根据时候保留最新数据。

数据的去重只会在数据合并期间进行。合并会在后台一个不确定的时间进行,因此你无法预先作出计划。有一些数据可能仍未被处理。通常使用OPTIMIZE 语句手动触发,比如今天程序异常停止了,我启动了程序, 大概率会有多个版本数据,这个时候需要手动合并一下:

OPTIMIZE table test.test_local on cluster default_cluster final;

这样会触发数据合并,这个过程耗费性能,正常情况下,如果没有多版本数据,不需要触发合并。如果没有触发,查询数据时候,会有多个版本,需要final关键字,查询时候合并一下,如果查询很多,将非常耗费性能,这个时候可以选择定期合并。

select * from test.test_all final where id = 10000

对于这种多个版本的表,有时候也是可以避开final的,比如去重,可以select distinct id from table,而不需要select distinct id from table final,这个final是可以省的,等等。

分布式表的删除

需要删除本地表和分布式表:

drop table test.test_local on cluster default_cluster;
drop table test.test_all on cluster default_cluster;

复杂数据类型map

有些业务数据某个字段是json类型,并且key数量不定,这个时候需要将其在ck里面定义为map类型,包容性好。

CREATE TABLE test.test_local2 on cluster default_cluster (
    `id` UInt64,
    `data` Map(String, String),
) ENGINE = ReplicatedMergeTree()
partition by dt
order by id;

CREATE TABLE test.test_all2 on cluster default_cluster as test.test_local2 ENGINE = Distributed('default_cluster', 'test', 'test_local2', sipHash64(id));

data Map(String, String) :这就定义了一个map类型字段,查询时候可以这样查:

select data['key'] from test.test_all2 limit 5

对于json,ck也是可以解的。

select JSONExtractRaw('{"a": "hello", "b": [-100, 200.0, 300]}', 'b')
-- 结果
'[-100, 200.0, 300]'

select JSONExtractString('{"a": "hello", "b": [-100, 200.0, 300]}', 'a')
-- 结果
'hello'

更详细官方文档:
https://clickhouse.com/docs/zh/sql-reference/functions/json-functions

关于成本

相比较而言,ck是能节省成本的,运维是这么说的。经常扩容磁盘,而计算性能只扩容了一次。

通常,构造函数具有public可访问性,但也可以将构造函数声明为 protected 或 private。构造函数可以选择采用成员初始化表达式列表,该列表会在构造函数主体运行之前初始化类成员。与在构造函数主体中赋值相比,初始化类成员是更高效的方式。首选成员初始化表达式列表,而不是在构造函数主体中赋值。

注意

  1. 成员初始化表达式的参数可以是构造函数参数之一、函数调用或 std::initializer_list
  2. const 成员和引用类型的成员必须在成员初始化表达式列表中进行初始化。
  3. 若要确保在派生构造函数运行之前完全初始化基类,需要在初始化表达式中初始化化基类构造函数。
class Box {
public:
    // Default constructor
    Box() {}

    // Initialize a Box with equal dimensions (i.e. a cube)
    explicit Box(int i) : m_width(i), m_length(i), m_height(i) // member init list
    {}

    // Initialize a Box with custom dimensions
    Box(int width, int length, int height)
        : m_width(width), m_length(length), m_height(height)
    {}

    int Volume() { return m_width * m_length * m_height; }

private:
    // Will have value of 0 when default constructor is called.
    // If we didn't zero-init here, default constructor would
    // leave them uninitialized with garbage values.
    int m_width{ 0 };
    int m_length{ 0 };
    int m_height{ 0 };
};

派生构造函数运行之前完全初始化基类

class Box {
public:
    Box(int width, int length, int height){
       m_width = width;
       m_length = length;
       m_height = height;
    }

private:
    int m_width;
    int m_length;
    int m_height;
};

class StorageBox : public Box {
public:
    StorageBox(int width, int length, int height, const string label&) : Box(width, length, height){
        m_label = label;
    }
private:
    string m_label;
};

构造函数可以声明为 inline、explicit、friend 或 constexpr。可以显式设置默认复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。

class Box2
{
public:
    Box2() = delete;
    Box2(const Box2& other) = default;
    Box2& operator=(const Box2& other) = default;
    Box2(Box2&& other) = default;
    Box2& operator=(Box2&& other) = default;
    //...
};

一、默认构造函数

如果类中未声明构造函数,则编译器提供隐式 inline 默认构造函数。编译器提供的默认构造函数没有参数。如果使用隐式默认构造函数,须要在类定义中初始化成员。

class Box {
public:
    int Volume() {return m_width * m_height * m_length;}
private:
    // 如果没有这些初始化表达式,成员会处于未初始化状态,Volume() 调用会生成垃圾值。
    int m_width { 0 };
    int m_height { 0 };
    int m_length { 0 };
};

如果声明了任何非默认构造函数,编译器不会提供默认构造函数。如果不使用编译器生成的构造函数,可以通过将隐式默认构造函数定义为已删除来阻止编译器生成它。

class Box {
public:
    // 只有没声明构造函数时此语句有效
    Box() = delete;
    Box(int width, int length, int height)
        : m_width(width), m_length(length), m_height(height){}
private:
    int m_width;
    int m_length;
    int m_height;

};
int main(){

    Box box1(1, 2, 3);
    Box box2{ 2, 3, 4 };
    Box box3; // 编译错误 C2512: no appropriate default constructor available
    Box boxes[3]; // 编译错误 C2512: no appropriate default constructor available
    Box boxes[3]{ { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } }; // 正确
}

二、显式构造函数

如果类的构造函数只有一个参数,或是除了一个参数之外的所有参数都具有默认值,则会发生隐式类型转换。

class Box {
public:
    Box(int size): m_width(size), m_length(size), m_height(size){}
private:
    int m_width;
    int m_length;
    int m_height;

};
class ShippingOrder
{
public:
    ShippingOrder(Box b, double postage) : m_box(b), m_postage(postage){}

private:
    Box m_box;
    double m_postage;
}
int main(){
    Box b = 42; // 隐式类型转换
    ShippingOrder so(42, 10.8); // 隐式类型转换
}

explicit关键字可以防止隐式类型转换的发生。explicit只能用于修饰
只有一个参数
的类构造函数,表明该构造函数是显示的而非隐式的。

  1. explicit关键字的作用就是防止类构造函数的隐式自动转换。
  2. 如果类构造函数参数大于或等于两个时, 不会产生隐式转换的, explicit关键字无效。
  3. 例外, 就是当除了第一个参数以外的其他参数都有默认值的时候, explicit关键字依然有效。
  4. explicit只能写在在声明中,不能写在定义中。

三、复制构造函数

从 C++11 中开始,支持两类赋值:复制赋值和移动赋值。赋值操作和初始化操作都会导致对象被复制。

赋值
:将一个对象的值分配给另一个对象时,第一个对象将复制到第二个对象。
初始化
:在声明新对象、按值传递函数参数或从函数返回值时,将发生初始化。

编译器默认会生成复制构造函数。如果类成员都是简单类型(如标量值),则编译器生成的复制构造函数已够用。 如果类需要更复杂的初始化,则需要实现自定义复制构造函数。例如,如果类成员是指针,编译器生成的复制构造函数只是复制指针,以便新指针仍指向原内存位置。

复制构造函数声明方式如下:

    Box(Box& other); // 尽量避免这种方式,这种方式允许修改other
    Box(const Box& other); // 尽量使用这种方式,它可防止复制构造函数意外更改复制的对象。
    Box(volatile Box& other);
    Box(volatile const Box& other);

    // 后续参数必须要有默认值
    Box(Box& other, int i = 42, string label = "Box");

    Box& operator=(const Box& x);

定义复制构造函数时,还应定义复制赋值运算符 (=)。如果不声明复制赋值运算符,编译器将自动生成复制赋值运算符。如果只声明复制构造函数,编译器自动生成复制赋值运算符;如果只声明复制赋值运算符,编译器自动生成复制构造函数。 如果未定义显式或隐式移动构造函数,则原本使用移动构造函数的操作会改用复制构造函数。 如果类声明了移动构造函数或移动赋值运算符,则隐式声明的复制构造函数会定义为已删除。

阻止复制对象时,需要将复制构造函数声明为delete。如果要禁止对象复制,应该这样做。

  Box (const Box& other) = delete;

三、移动构造函数

当对象由相同类型的另一个对象初始化时,如果另一对象即将被毁且不再需要其资源,则编译器会选择移动构造函数。 移动构造函数在传递大型对象时可以显著提高程序的效率。

#include "MemoryBlock.h"
#include <vector>

using namespace std;

int main()
{
   // vector 类使用移动语义,通过移动矢量元素(而非复制它们)来高效地执行插入操作。
   vector<MemoryBlock> v;
  // 如果 MemoryBlock 没有定义移动构造函数,会按照以下顺序执行
  // 1. 创建对象 MemoryBlock(25)
  // 2. 复制 MemoryBlock 给push_back
  // 3. 删除 MemoryBlock 对象
   v.push_back(MemoryBlock(25));
  // 如果 MemoryBlock 有移动构造函数,按照以下顺序执行
  // 1. 创建对象 MemoryBlock(25)
  // 2. 执行push_back时会调用移动构造函数,直接使用MemoryBlock对象而不是复制
   v.push_back(MemoryBlock(75));

}

创建移动构造函数

  1. 定义一个空的构造函数,构造函数的参数类型为右值引用;
  2. 在移动构造函数中,将源对象中的类数据成员添加到要构造的对象;
  3. 将源对象的数据成员置空。 这可以防止析构函数多次释放资源(如内存)。
MemoryBlock(MemoryBlock&& other)
   : _data(nullptr)
   , _length(0)
{
    _data = other._data;
    _length = other._length;
    other._data = nullptr;
    other._length = 0;
}

创建移动赋值运算符

  1. 定义一个空的赋值运算符,该运算符参数类型为右值引用,返回一个引用类型;
  2. 防止将对象赋给自身;
  3. 释放目标对象中所有资源(如内存),将数据成员从源对象转移到要构造的对象;
  4. 返回对当前对象的引用。
MemoryBlock& operator=(MemoryBlock&& other)
{
    if (this != &other)
    {
        delete[] _data;
        _data = other._data;
        _length = other._length;

        other._data = nullptr;
        other._length = 0;
    }

    return *this;
}

如果同时提供了移动构造函数和移动赋值运算符,则可以编写移动构造函数来调用移动赋值运算符,从而消除冗余代码。

MemoryBlock(MemoryBlock&& other) noexcept
   : _data(nullptr)
   , _length(0)
{
   *this = std::move(other);
}

四、委托构造函数

委托构造函数就是调用同一类中的其他构造函数,完成部分初始化工作。 可以在一个构造函数中编写主逻辑,并从其他构造函数调用它。委托构造函数可以减少代码重复,使代码更易于了解和维护。

class Box {
public:
    // 默认构造函数
    Box() {}

    // 构造函数
    Box(int i) :  Box(i, i, i)  // 委托构造函数
    {}

    // 构造函数,主逻辑
    Box(int width, int length, int height)
        : m_width(width), m_length(length), m_height(height)
    {}
};

注意:不能在委托给其他构造函数的构造函数中执行成员初始化

class class_a {
public:
    class_a() {}
    // 成员初始化,未使用代理
    class_a(string str) : m_string{ str } {}

    // 使用代理时不能在此初始化成员,否则会出现以下错误
    // error C3511: a call to a delegating constructor shall be the only member-initializer
    class_a(string str, double dbl) : class_a(str) , m_double{ dbl } {}

    // 其它成员正确的初始化方式
    class_a(string str, double dbl) : class_a(str) { m_double = dbl; }

    double m_double{ 1.0 };
    string m_string;
};

注意:构造函数委托语法能循环调用,否则会出现堆栈溢出。

class class_f{
public:
    int max;
    int min;

    // 这样做语法上允许,但是会在运行时出现堆栈溢出
    class_f() : class_f(6, 3){ }
    class_f(int my_max, int my_min) : class_f() { }
};

五、继承构造函数

派生类可以使用 using 声明从直接基类继承构造函数。一般而言,当派生类未声明新数据成员或构造函数时,最好使用继承构造函数。如果基类的构造函数具有相同签名,则派生类无法从多个基类继承。

#include <iostream>
using namespace std;

class Base
{
public:
    Base() { cout << "Base()" << endl; }
    Base(const Base& other) { cout << "Base(Base&)" << endl; }
    explicit Base(int i) : num(i) { cout << "Base(int)" << endl; }
    explicit Base(char c) : letter(c) { cout << "Base(char)" << endl; }

private:
    int num;
    char letter;
};

class Derived : Base
{
public:
    // 从基类 Base 继承全部构造函数
    using Base::Base;

private:
    // 基类构造函数无法初始化该成员
    int newMember{ 0 };
};

int main()
{
    cout << "Derived d1(5) calls: ";
    Derived d1(5);
    cout << "Derived d1('c') calls: ";
    Derived d2('c');
    cout << "Derived d3 = d2 calls: " ;
    Derived d3 = d2;
    cout << "Derived d4 calls: ";
    Derived d4;
}

/* Output:
Derived d1(5) calls: Base(int)
Derived d1('c') calls: Base(char)
Derived d3 = d2 calls: Base(Base&)
Derived d4 calls: Base()*/

类模板可以从类型参数继承所有构造函数:

template< typename T >
class Derived : T {
    using T::T;   // declare the constructors from T
    // ...
};

构造函数执行顺序

  1. 按声明顺序调用基类和成员构造函数。
  2. 如果类派生自虚拟基类,则会将对象的虚拟基指针初始化。
  3. 如果类具有或继承了虚函数,则会将对象的虚函数指针初始化。 虚函数指针指向类中的虚函数表,确保虚函数正确地调用绑定代码。
  4. 执行自己函数体中的所有代码。

如果基类没有默认构造函数,则必须在派生类构造函数中提供基类构造函数参数

下面代码,首先,调用基构造函数。 然后,按照在类声明中出现的顺序初始化基类成员。 最后,调用派生构造函数。

#include <iostream>

using namespace std;

class Contained1 {
public:
    Contained1() { cout << "Contained1 ctor\n"; }
};

class Contained2 {
public:
    Contained2() { cout << "Contained2 ctor\n"; }
};

class Contained3 {
public:
    Contained3() { cout << "Contained3 ctor\n"; }
};

class BaseContainer {
public:
    BaseContainer() { cout << "BaseContainer ctor\n"; }
private:
    Contained1 c1;
    Contained2 c2;
};

class DerivedContainer : public BaseContainer {
public:
    DerivedContainer() : BaseContainer() { cout << "DerivedContainer ctor\n"; }
private:
    Contained3 c3;
};

int main() {
    DerivedContainer dc;
}

输出如下:
Contained1 ctor
Contained2 ctor
BaseContainer ctor
Contained3 ctor
DerivedContainer ctor

参考文章:
构造函数 (C++)
QT学习记录(008):explicit 关键字的作用
C++中的explicit详解

前言

我们每天都在使用
defineEmits
宏函数,但是你知道
defineEmits
宏函数经过编译后其实就是vue2的选项式API吗?通过回答下面两个问题,我将逐步为你揭秘
defineEmits
宏函数的神秘面纱。为什么 Vue 的
defineEmits
宏函数不需要 import 导入就可用?为什么
defineEmits
的返回值等同于
$emit
方法用于在组件中抛出事件?

举两个例子

要回答上面提的几个问题我们先来看两个例子是如何声明事件和抛出事件,分别是vue2的选项式语法和vue3的组合式语法。

我们先来看vue2的选项式语法的例子,
options-child.vue
文件代码如下:

<template>
  <button @click="handleClick">放大文字</button>
</template>

<script>
export default {
  name: "options-child",
  emits: ["enlarge-text"],
  methods: {
    handleClick() {
      this.$emit("enlarge-text");
    },
  },
};
</script>

使用
emits
选项声明了要抛出的事件"enlarge-text",然后在点击按钮后调用
this.$emit
方法抛出
"enlarge-text"
事件。这里的this大家都知道是指向的当前组件的vue实例,所以
this.$emit
是调用的当前vue实例的
$emit
方法。
大家先记住vue2的选项式语法例子,后面我们讲
defineEmits
宏函数编译原理时会用。

我们再来看看vue3的组合式语法的例子,
composition-child.vue
代码如下:

<template>
  <button @click="handleClick">放大文字</button>
</template>

<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);
function handleClick() {
  emits("enlarge-text");
}
</script>

在这个例子中我们使用了
defineEmits
宏函数声明了要抛出的事件"enlarge-text",
defineEmits
宏函数执行后返回了一个
emits
函数,然后在点击按钮后使用
emits("enlarge-text")
抛出
"enlarge-text"
事件。

通过debug搞清楚上面几个问题

首先我们要搞清楚应该在哪里打断点,在我之前的文章
vue文件是如何编译为js文件
中已经带你搞清楚了将vue文件中的
<script>
模块编译成浏览器可直接运行的js代码,底层就是调用
vue/compiler-sfc
包的
compileScript
函数。当然如果你还没看过我的
vue文件是如何编译为js文件
文章也不影响这篇文章阅读。

所以我们将断点打在
vue/compiler-sfc
包的
compileScript
函数中,一样的套路,首先我们在vscode的打开一个debug终端。
debug-terminal

然后在node_modules中找到
vue/compiler-sfc
包的
compileScript
函数打上断点,
compileScript
函数位置在
/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
。在debug终端上面执行
yarn dev
后在浏览器中打开对应的页面,比如:
http://localhost:5173/
。此时断点就会走到
compileScript
函数中,由于每编译一个
vue
文件都要走到这个debug中,现在我们只想
debug
看看
composition-child.vue
文件,也就是我们前面举的vue3的组合式语法的例子。所以为了方便我们在
compileScript
中加了下面这样一段代码,并且去掉了在
compileScript
函数中加的断点,这样就只有编译
composition-child.vue
文件时会走进断点。加的这段代码中的
sfc.fileName
就是文件路径的意思,后面我们会讲。
debug-terminal

compileScript
函数

我们再来回忆一下
composition-child.vue
文件中的
script
模块代码如下:

<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);

function handleClick() {
  emits("enlarge-text");
}
</script>

compileScript
函数内包含了编译
script
模块的所有的逻辑,代码很复杂,光是源代码就接近1000行。这篇文章我们同样不会去通读
compileScript
函数的所有功能,只讲涉及到
defineEmits
流程的代码。这个是根据我们这个场景将
compileScript
函数简化后的代码:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const startOffset = ctx.startOffset;
  const endOffset = ctx.endOffset;
  const scriptSetupAst = ctx.scriptSetupAst;

  for (const node of scriptSetupAst.body) {
    if (node.type === "ExpressionStatement") {
      // ...
    }

    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          const isDefineEmits = processDefineEmits(ctx, init, decl.id);
          if (isDefineEmits) {
            ctx.s.overwrite(
              startOffset + init.start,
              startOffset + init.end,
              "__emit"
            );
          }
        }
      }
    }

    if (
      (node.type === "VariableDeclaration" && !node.declare) ||
      node.type.endsWith("Statement")
    ) {
      // ....
    }
  }

  ctx.s.remove(0, startOffset);
  ctx.s.remove(endOffset, source.length);

  let runtimeOptions = ``;
  const emitsDecl = genRuntimeEmits(ctx);
  if (emitsDecl) runtimeOptions += `\n  emits: ${emitsDecl},`;

  const def =
    (defaultExport ? `\n  ...${normalScriptDefaultVar},` : ``) +
    (definedOptions ? `\n  ...${definedOptions},` : "");
  ctx.s.prependLeft(
    startOffset,
    `\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
      `defineComponent`
    )}({${def}${runtimeOptions}\n  ${
      hasAwait ? `async ` : ``
    }setup(${args}) {\n${exposeCall}`
  );
  ctx.s.appendRight(endOffset, `})`);

  return {
    //....
    content: ctx.s.toString(),
  };
}

如果看过我上一篇
为什么defineProps宏函数不需要从vue中import导入?
文章的小伙伴应该会很熟悉这个
compileScript
函数,
compileScript
函数内处理
defineProps

defineEmits
大体流程其实很相似的。

ScriptCompileContext类

我们将断点走到
compileScript
函数中的第一部分代码。

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const startOffset = ctx.startOffset;
  const endOffset = ctx.endOffset;
  const scriptSetupAst = ctx.scriptSetupAst;
  // ...省略
  return {
    //....
    content: ctx.s.toString(),
  };
}

这部分代码主要使用
ScriptCompileContext
类new了一个
ctx
上下文对象,并且读取了上下文对象中的
startOffset

endOffset

scriptSetupAst

s
四个属性。我们将断点走进
ScriptCompileContext
类,看看他的
constructor
构造函数。下面这个是我简化后的
ScriptCompileContext
类的代码:

import MagicString from 'magic-string'

class ScriptCompileContext {
  source = this.descriptor.source
  s = new MagicString(this.source)
  startOffset = this.descriptor.scriptSetup?.loc.start.offset
  endOffset = this.descriptor.scriptSetup?.loc.end.offset

  constructor(descriptor, options) {
    this.descriptor = descriptor;
    this.s = new MagicString(this.source);
    this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset);
  }
}


compileScript
函数中
new ScriptCompileContext
时传入的第一个参数是
sfc
变量,然后在
ScriptCompileContext
类的构造函数中是使用
descriptor
变量来接收,接着赋值给
descriptor
属性。

在之前的
vue文件是如何编译为js文件
文章中我们已经讲过了传入给
compileScript
函数的
sfc
变量是一个
descriptor
对象,
descriptor
对象是由vue文件编译来的。
descriptor
对象拥有template属性、scriptSetup属性、style属性、source属性,分别对应vue文件的
<template>
模块、
<script setup>
模块、
<style>
模块、源代码code字符串。在我们这个场景只关注
scriptSetup

source
属性就行了,其中
sfc.scriptSetup.content
的值就是
<script setup>
模块中code代码字符串。详情查看下图:
composition-child

现在我想你已经搞清楚了
ctx
上下文对象4个属性中的
startOffset
属性和
endOffset
属性了,
startOffset

endOffset
分别对应的就是
descriptor.scriptSetup?.loc.start.offset

descriptor.scriptSetup?.loc.end.offset

startOffset

<script setup>
模块中的内容开始的位置。
endOffset

<script setup>
模块中的内容结束的位置。

我们接着来看构造函数中的
this.s = new MagicString(this.source)
这段话,
this.source
是vue文件中的源代码code字符串,以这个字符串new了一个
MagicString
对象赋值给
s
属性。
magic-string
是一个用于高效操作字符串的 JavaScript 库。它提供丰富的 API,可以轻松地对字符串进行插入、删除、替换等操作。我们这里主要用到
toString

remove

overwrite

prependLeft

appendRight
五个方法。
toString
方法用于生成经过处理后返回的字符串,其余几个方法我举几个例子你应该就明白了。

s.remove( start, end )
用于删除从开始到结束的字符串:

const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'

s.overwrite( start, end, content )
,使用
content
的内容替换开始位置到结束位置的内容。

const s = new MagicString('hello word');
s.overwrite(0, 5, "你好");
s.toString(); // '你好 word'

s.prependLeft( index, content )
用于在指定
index
的前面插入字符串:

const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'

s.appendRight( index, content )
用于在指定
index
的后面插入字符串:

const s = new MagicString('hello word');
s.appendRight(5, 'xx');
s.toString(); // 'helloxx word'

现在你应该已经明白了
ctx
上下文对象中的
s
属性了,我们接着来看最后一个属性
scriptSetupAst
。在构造函数中是由
parse
函数的返回值赋值的:
this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset)

parse
函数的代码如下:

import { parse as babelParse } from '@babel/parser'

function parse(input: string, offset: number): Program {
  try {
    return babelParse(input, {
      plugins,
      sourceType: 'module',
    }).program
  } catch (e: any) {
  }
}

我们在前面已经讲过了
descriptor.scriptSetup.content
的值就是
vue
文件中的
<script setup>
模块的代码
code
字符串,
parse
函数中调用了
babel
提供的
parser
函数,将
vue
文件中的
<script setup>
模块的代码
code
字符串转换成
AST抽象语法树


ScriptCompileContext
构造函数中主要做了下面这些事情:
progress1

processDefineEmits函数

我们接着将断点走到
compileScript
函数中的第二部分,
for
循环遍历AST抽象语法树的地方,代码如下:

function compileScript(sfc, options) {
  // ...省略
  for (const node of scriptSetupAst.body) {
    if (node.type === "ExpressionStatement") {
      // ...
    }

    if (node.type === "VariableDeclaration" && !node.declare) {
      const total = node.declarations.length;
      for (let i = 0; i < total; i++) {
        const decl = node.declarations[i];
        const init = decl.init;
        if (init) {
          const isDefineEmits = processDefineEmits(ctx, init, decl.id);
          if (isDefineEmits) {
            ctx.s.overwrite(
              startOffset + init.start,
              startOffset + init.end,
              "__emit"
            );
          }
        }
      }
    }

    if (
      (node.type === "VariableDeclaration" && !node.declare) ||
      node.type.endsWith("Statement")
    ) {
      // ....
    }
  }
  // ...省略
}

看过我上一篇
为什么defineProps宏函数不需要从vue中import导入?
可能会疑惑了,为什么这里不列出满足
node.type === "ExpressionStatement"
条件的代码呢。原因是在上一篇文章中我们没有将
defineProps
函数的返回值赋值给一个变量,他是一条表达式语句,所以满足
node.type === "ExpressionStatement"
的条件。在这篇文章中我们将
defineEmits
函数的返回值赋值给一个
emits
变量,他是一条变量声明语句,所以他满足
node.type === "VariableDeclaration"
的条件。

// 表达式语句
defineProps({
  content: String,
});

// 变量声明语句
const emits = defineEmits(["enlarge-text"]);

将断点走进for循环里面,我们知道在script模块中第一行代码是变量声明语句
const emits = defineEmits(["enlarge-text"]);
。在console中看看由这条变量声明语句编译成的node节点长什么样子,如下图:
first-node

从上图中我们可以看到当前的
node
节点类型为变量声明语句,并且
node.declare
的值为
undefined
。我们再来看看
node.declarations
字段,他表示该节点的所有声明子节点。这句话是什么意思呢?说人话就是表示const右边的语句。那为什么
declarations
是一个数组呢?那是因为const右边可以有多条语句,比如
const a = 2, b = 4;
。在我们这个场景
node.declarations
字段就是表示
emits = defineEmits(["enlarge-text"]);
。接着来看
declarations
数组下的
init
字段,从名字我想你应该已经猜到了他的作用是表示变量的初始化值,在我们这个场景
init
字段就是表示
defineEmits(["enlarge-text"])
。而
init.start
表示
defineEmits(["enlarge-text"]);
中的开始位置,也就是字符串'd'的位置,
init.end
表示
defineEmits(["enlarge-text"]);
中的结束位置,也就是字符串';'的位置。

现在我们将断点走到if语句内,下面的这些代码我想你应该能够很轻松的理解了:

if (node.type === "VariableDeclaration" && !node.declare) {
  const total = node.declarations.length;
  for (let i = 0; i < total; i++) {
    const decl = node.declarations[i];
    const init = decl.init;
    if (init) {
      const isDefineEmits = processDefineEmits(ctx, init, decl.id);
      // 省略...
    }
  }
}

我们在控制台中已经看到了
node.declare
的值是
undefined
,并且这也是一条变量声明语句,所以断点会走到if里面。由于我们这里只声明了一个变量,所以
node.declarations
数组中只有一个值,这个值就是对应的
emits = defineEmits(["enlarge-text"]);
。接着遍历
node.declarations
数组,将数组中的item赋值给
decl
变量,然后使用
decl.init
读取到变量声明语句中的初始化值,在我们这里初始化值就是
defineEmits(["enlarge-text"]);
。如果有初始化值,那就将他传入给
processDefineEmits
函数判断是否在调用
defineEmits
函数。我们来看看
processDefineEmits
函数是什么样的:

const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
  if (!isCallOf(node, DEFINE_EMITS)) {
    return false;
  }
  ctx.emitsRuntimeDecl = node.arguments[0];
  return true;
}


processDefineEmits
函数中,我们首先使用
isCallOf
函数判断当前的 AST 语法树节点 node 是否在调用
defineEmits
函数。
isCallOf
函数的第一个参数是 node 节点,第二个参数在这里是写死的字符串 "defineEmits"。isCallOf的代码如下:

export function isCallOf(node, test) {
  return !!(
    node &&
    test &&
    node.type === "CallExpression" &&
    node.callee.type === "Identifier" &&
    (typeof test === "string"
      ? node.callee.name === test
      : test(node.callee.name))
  );
}

我们在debug console中将
node.type

node.callee.type

node.callee.name
的值打印出来看看。
isCallOf

从图上看到
node.type

node.callee.type

node.callee.name
的值后,我们知道了当前节点确实是在调用
defineEmits
函数。所以
isCallOf(node, DEFINE_EMITS)
的执行结果为 true,在
processDefineEmits
函数中我们是对
isCallOf
函数的执行结果取反,所以
!isCallOf(node, DEFINE_EMITS)
的执行结果为 false。

我们接着来看
processDefineEmits
函数:

const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
  if (!isCallOf(node, DEFINE_EMITS)) {
    return false;
  }
  ctx.emitsRuntimeDecl = node.arguments[0];
  return true;
}

如果是在执行
defineEmits
函数,就会执行接下来的代码
ctx.emitsRuntimeDecl = node.arguments[0];
。将传入的
node
节点第一个参数赋值给
ctx
上下文对象的
emitsRuntimeDecl
属性,这里的第一个参数其实就是调用
defineEmits
函数时给传入的第一个参数。为什么写死成取
arguments[0]
呢?是因为
defineEmits
函数只接收一个参数,传入的参数可以是一个对象或者数组。比如:

const props = defineEmits({
  'enlarge-text': null
})

const emits = defineEmits(['enlarge-text'])

记住这个在
ctx
上下文上面塞的
emitsRuntimeDecl
属性,后面会用到。

至此我们已经了解到了
processDefineEmits
中主要做了两件事:判断当前执行的表达式语句是否是
defineEmits
函数,如果是那么就将调用
defineEmits
函数时传入的参数转换成的node节点塞到
ctx
上下文的
emitsRuntimeDecl
属性中。

我们接着来看
compileScript
函数中的代码:

if (node.type === "VariableDeclaration" && !node.declare) {
  const total = node.declarations.length;
  for (let i = 0; i < total; i++) {
    const decl = node.declarations[i];
    const init = decl.init;
    if (init) {
      const isDefineEmits = processDefineEmits(ctx, init, decl.id);
      if (isDefineEmits) {
        ctx.s.overwrite(
          startOffset + init.start,
          startOffset + init.end,
          "__emit"
        );
      }
    }
  }
}


processDefineEmits
函数的执行结果赋值赋值给
isDefineEmits
变量,在我们这个场景当然是在调用
defineEmits
函数,所以会执行if语句内的
ctx.s.overwrite
方法。
ctx.s.overwrite
方法我们前面已经讲过了,作用是使用指定的内容替换开始位置到结束位置的内容。在执行
ctx.s.overwrite
前我们先在debug console中执行
ctx.s.toString()
看看当前的code代码字符串是什么样的。
before-overwrite

从上图我们可以看到此时的code代码字符串还是和我们的源代码是一样的,我们接着来看
ctx.s.overwrite
方法接收的参数。第一个参数为
startOffset + init.start

startOffset
我们前面已经讲过了他的值为
script
模块的内容开始的位置。
init
我们前面也讲过了,他表示
emits
变量的初始化值对应的node节点,在我们这个场景
init
字段就是表示
defineEmits(["enlarge-text"])
。所以
init.start

emits
变量的初始化值在
script
模块中开始的位置。而
ctx.s.
为操纵整个vue文件的code代码字符串,所以
startOffset + init.start
的值为
emits
变量的初始化值的起点在整个vue文件的code代码字符串所在位置。同理第二个参数
startOffset + init.end
的值为
emits
变量的初始化值的终点在整个vue文件的code代码字符串所在位置,而第三个参数是一个写死的字符串"__emit"。所以
ctx.s.overwrite
方法的作用是将
const emits = defineEmits(["enlarge-text"]);
替换为
const emits = __emit;

关于
startOffset

init.start

init.end
请看下图:
params-overwrite

在执行
ctx.s.overwrite
方法后我们在debug console中再次执行
ctx.s.toString()
看看这会儿的code代码字符串是什么样的。
after-overwrite

从上图中我们可以看到此时代码中已经没有了
defineEmits
函数,已经变成了一个
__emit
变量。
convert-defineEmits

genRuntimeEmits函数

我们接着将断点走到
compileScript
函数中的第三部分,生成运行时的“声明事件”。我们在上一步将
defineEmits
声明事件的代码替换为
__emit
,那么总得有一个地方去生成“声明事件”。没错,就是在
genRuntimeEmits
函数这里生成的。
compileScript
函数中执行
genRuntimeEmits
函数的代码如下:

ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);

let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n  emits: ${emitsDecl},`;

从上面的代码中我们看到首先执行了两次
remove
方法,在前面已经讲过了
startOffset

script
模块中的内容开始的位置。所以
ctx.s.remove(0, startOffset);
的意思是删除掉
template
模块的内容和
<script setup>
开始标签。这行代码执行完后我们再看看
ctx.s.toString()
的值:
remove1

从上图我们可以看到此时
template
模块和
<script setup>
开始标签已经没有了,接着执行
ctx.s.remove(endOffset, source.length);
,这行代码的意思是删除
</script >
结束标签和
<style>
模块。这行代码执行完后我们再来看看
ctx.s.toString()
的值:
remove2

从上图我们可以看到,此时只有script模块中的内容了。

我们接着将
compileScript
函数中的断点走到调用
genRuntimeEmits
函数处,简化后代码如下:

function genRuntimeEmits(ctx) {
  let emitsDecl = "";
  if (ctx.emitsRuntimeDecl) {
    emitsDecl = ctx.getString(ctx.emitsRuntimeDecl).trim();
  }
  return emitsDecl;
}

看到上面的代码是不是觉得和上一篇
defineProps
文章中讲的
genRuntimeProps
函数很相似。这里的上下文
ctx
上面的
emitsRuntimeDecl
属性我们前面讲过了,他就是调用
defineEmits
函数时传入的参数转换成的node节点。我们将断点走进
ctx.getString
函数,代码如下:

getString(node, scriptSetup = true) {
  const block = scriptSetup ? this.descriptor.scriptSetup : this.descriptor.script;
  return block.content.slice(node.start, node.end);
}

我们前面已经讲过了
descriptor
对象是由
vue
文件编译而来,其中的
scriptSetup
属性就是对应的
<script setup>
模块。我们这里没有传入
scriptSetup
,所以
block
的值为
this.descriptor.scriptSetup
。同样我们前面也讲过
scriptSetup.content
的值是
<script setup>
模块
code
代码字符串。请看下图:
script-code

这里传入的
node
节点就是我们前面存在上下文中
ctx.emitsRuntimeDecl
,也就是在调用
defineEmits
函数时传入的参数节点,
node.start
就是参数节点开始的位置,
node.end
就是参数节点的结束位置。所以使用
content.slice
方法就可以截取出来调用
defineEmits
函数时传入的参数。请看下图:
block-slice

现在我们再回过头来看
compileScript
函数中的调用
genRuntimeEmits
函数的代码你就能很容易理解了:

let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n  emits: ${emitsDecl},`;

这里的
emitsDecl
在我们这个场景中就是使用
slice
截取出来的
emits
定义,再使用字符串拼接
emits:
,就得到了
runtimeOptions
的值。如图:
runtimeOptions

看到
runtimeOptions
的值是不是就觉得很熟悉了,又有
name
属性,又有
emits
属性,和我们前面举的两个例子中的vue2的选项式语法的例子比较相似。
genRuntimeEmits

拼接成完整的浏览器运行时
js
代码

我们接着将断点走到
compileScript
函数中的最后一部分:

const def =
  (defaultExport ? `\n  ...${normalScriptDefaultVar},` : ``) +
  (definedOptions ? `\n  ...${definedOptions},` : "");
ctx.s.prependLeft(
  startOffset,
  `\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
    `defineComponent`
  )}({${def}${runtimeOptions}\n  ${
    hasAwait ? `async ` : ``
  }setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);

return {
  //....
  content: ctx.s.toString(),
};

这块代码和我们讲
defineProps
文章中是一样的,先调用了
ctx.s.prependLeft
方法给字符串开始的地方插入了一串字符串,这串拼接的字符串看着很麻烦的样子,我们直接在debug console上面看看要拼接的字符串是什么样的:
prependLeft

看到这串你应该很熟悉,除了前面我们拼接的
name

emits
之外还有部分
setup
编译后的代码,但是这里的
setup
代码还不完整,剩余部分还在
ctx.s.toString()
里面。

将断点执行完
ctx.s.prependLeft
后,我们在debug console上面通过
ctx.s.toString()
看此时操作的字符串变成什么样了:
after-prependLeft

从上图可以看到此时的setup函数已经拼接完整了,已经是一个编译后的
vue
组件对象的代码字符串了,只差一个
})
结束符号,所以执行
ctx.s.appendRight
方法将结束符号插入进去。

我们最后再来看看经过
compileScript
函数处理后的浏览器可执行的
js
代码字符串,也就是
ctx.s.toString()
full-code

从上图中我们可以看到编译后的代码中
声明事件
还是通过vue组件对象上面的
emits
选项声明的,和我们前面举的vue2的选项式语法的例子一模一样。

为什么
defineEmits
的返回值等同于
$emit
方法用于在组件中抛出事件?

在上一节中我们知道了
defineEmits
函数在编译时就被替换为了
__emit
变量,然后将
__emit
赋值给我们定义的
emits
变量。在需要抛出事件时我们是调用的
emits("enlarge-text");
,实际就是在调用
__emit("enlarge-text");
。那我们现在通过debug看看这个
__emit
到底是什么东西?

首先我们需要在浏览器的source面板中找到由vue文件编译而来的js文件,然后给setup函数打上断点。在我们前面的
Vue 3 的 setup语法糖到底是什么东西?
文章中已经手把手的教你了怎么在浏览器中找到编译后的js文件,所以在这篇文章中就不再赘述了。


setup
函数打上断点,刷新浏览器页面后,我们看到断点已经走进来了。如图:
setup-debug

从上图中我们可以看见
defineEmits
的返回值也就是
__emit
变量,实际就是
setup
函数的第二个参数对象中的
emit
属性。右边的Call Stack有的小伙伴可能不常用,他的作用是追踪函数的执行流。比如在这里
setup
函数是由
callWithErrorHandling
函数内调用的,在Call Stack中
setup
下面就是
callWithErrorHandling
。而
callWithErrorHandling
函数是由
setupStatefulComponent
函数内调用的,所以在Call Stack中
callWithErrorHandling
下面就是
setupStatefulComponent
。并且还可以通过点击函数名称跳转到对应的函数中。

为了搞清楚
setup
函数的第二个参数到底是什么,所以我们点击右边的Call Stack中的
callWithErrorHandling
函数,看看在
callWithErrorHandling
函数中是怎么调用
setup
函数的。代码如下:

function callWithErrorHandling(fn, instance, type, args) {
  try {
    return args ? fn(...args) : fn();
  } catch (err) {
    handleError(err, instance, type);
  }
}

从上面的代码中可以看到这个
callWithErrorHandling
函数实际就是用于错误处理的,如果有参数
args
,那就调用
fn
时将参数以
...args
的形式传入给
fn
。在我们这里
fn
就是
setup
函数,我们现在要看传递给
setup
的第二个参数,就对应的这里的是
args
数组中的第二项。现在我们知道了调用
callWithErrorHandling
函数时传入的第四个参数是一个数组,数组的第二项就是调用
setup
函数时传入的第二个参数对象。

我们接着来看在
setupStatefulComponent
函数中是如何调用
callWithErrorHandling
函数的,简化后代码如下:

function setupStatefulComponent(instance, isSSR) {
  const setupContext = (instance.setupContext =
    setup.length > 1 ? createSetupContext(instance) : null);
  const setupResult = callWithErrorHandling(setup, instance, 0, [
    true ? shallowReadonly(instance.props) : instance.props,
    setupContext,
  ]);
}

从上面的代码中可以看到调用
callWithErrorHandling
函数时传入的第四个参数确实是一个数组,数组的第二项是
setupContext
,这个
setupContext
就是调用
setup
函数时传入的第二个参数对象。而
setupContext
的值是由
createSetupContext
函数返回的,在调用
createSetupContext
函数时传入了当前的vue实例。我们接着来看简化后的
createSetupContext
函数是什么样的:

function createSetupContext(instance) {
  return Object.freeze({
    get attrs() {
      return getAttrsProxy(instance);
    },
    get slots() {
      return getSlotsProxy(instance);
    },
    get emit() {
      return (event, ...args) => instance.emit(event, ...args);
    },
    expose,
  });
}

这里出现了一个我们平时不常用的
Object.freeze
方法,在mdn上面查了一下他的作用:

Object.freeze()
静态方法可以使一个对象被
冻结
。冻结对象可以
防止扩展
,并使现有的属性不可写入和不可配置。被冻结的对象不能再被更改:不能添加新的属性,不能移除现有的属性,不能更改它们的可枚举性、可配置性、可写性或值,对象的原型也不能被重新指定。
freeze()
返回与传入的对象相同的对象。

从前面我们已经知道了
createSetupContext
函数的返回值就是调用
setup
函数时传入的第二个参数对象,我们要找的
__emit
就是第二个参数对象中的
emit
属性。当读取
emit
属性时就会走到上面的冻结对象的
get emit()
中,当我们调用
emit
函数抛出事件时实际就是调用的是
instance.emit
方法,也就是
vue
实例上面的
emit
方法。

现在我想你应该已经反应过来了,调用
defineEmits
函数的返回值实际就是在调用vue实例上面的emit方法,其实在运行时抛出事件的做法还是和vue2的选项式语法一样的,只是在编译时就将看着高大上的
defineEmits
函数编译成vue2的选项式语法的样子。
full-emit-progress

总结

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

  • 为什么 Vue 的
    defineEmits
    宏函数不需要 import 导入就可用?
    在遍历script模块转换成的AST抽象语法树时,如果当前的node节点是在调用
    defineEmits
    函数,就继续去找这个node节点下面的参数节点,也就是调用
    defineEmits
    函数传入的参数对应的node节点。然后将参数节点对象赋值给当前的
    ctx
    上下文的
    emitsRuntimeDecl
    属性中,接着根据
    defineEmits
    函数对应的node节点中记录的start和end位置对vue文件的code代码字符串进行替换。将
    defineEmits(["enlarge-text"])
    替换为
    __emit
    ,此时在代码中已经就没有了
    defineEmits
    宏函数了,自然也不需要从vue中import导入。当遍历完AST抽象语法树后调用
    genRuntimeEmits
    函数,从前面存的
    ctx
    上下文中的
    emitsRuntimeDecl
    属性中取出来调用
    defineEmits
    函数时传入的参数节点信息。根据参数节点中记录的start和end位置,对script模块中的code代码字符串执行slice方法,截取出调用
    defineEmits
    函数时传入的参数。然后通过字符串拼接的方式将调用
    defineEmits
    函数时传入的参数拼接到vue组件对象的emits属性上。

  • 为什么
    defineEmits
    的返回值等同于
    $emit
    方法用于在组件中抛出事件?
    defineEmits
    宏函数在上个问题中我们已经讲过了会被替换为
    __emit
    ,而这个
    __emit
    是调用
    setup
    函数时传入的第二个参数对象上的
    emit
    属性。而第二个参数对象是在
    setupStatefulComponent
    函数中调用
    createSetupContext
    函数生成的
    setupContext
    对象。在
    createSetupContext
    函数中我们看到返回的
    emit
    属性其实就是一个箭头函数,当调用
    defineEmits
    函数返回的
    emit
    函数时就会调用这个箭头函数,在箭头函数中其实是调用vue实例上的
    emit
    方法。

搞明白了上面两个问题我想你现在应该明白了为什么说
vue3的defineEmits 宏函数编译后其实就是vue2的选项式API

defineEmits
宏函数声明的事件经过编译后就变成了vue组件对象上的
emits
属性。
defineEmits
函数的返回值
emit
函数,其实就是在调用vue实例上的
emit
方法,这不就是我们在vue2的选项式API中声明事件和触发事件的样子吗。大部分看着高大上的黑魔法其实都是编译时做的事情,
vue3中的像
defineEmits
这样的宏函数经过编译后其实还是我们熟悉的vue2的选项式API。

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

Linq的学习

这里继续使用之前文章创建的学生类,首先简单介绍一下linq的使用。

Student.cs

 public class Student
 {
     public int Id { get; set; }
     public int ClassId { get; set; }

     public string Name { get; set; }

     public int Age { get; set; }

     public string Description { get; set; }

     public void Study()
     {
         Console.WriteLine($"{this.Id} {this.Name} 跟着老师学习 .Net开发");

     }

     public void StudyQt()
     {
         Console.WriteLine($"{this.Id} {this.Name} 跟着老师学习C++ Qt");
     }
 }

初始化学生数据

public class LinqPrinciple
{
    private List<Student> GetStudentsList()
    {
        List<Student> students = new List<Student>()
        { new Student() { Id = 1, ClassId = 1, Name = "张三", Age = 20, Description = "张三是一个好学生" },
             new Student() { Id = 2, ClassId = 1, Name = "李四", Age = 21, Description = "李四是一个好学生" },
             new Student() { Id = 3, ClassId = 2, Name = "王五", Age = 22, Description = "王五是一个好学生" },
             new Student() { Id = 4, ClassId = 2, Name = "赵六", Age = 23, Description = "赵六是一个好学生" },
             new Student() { Id = 5, ClassId = 3, Name = "孙七", Age = 24, Description = "孙七是一个好学生" },
             new Student() { Id = 6, ClassId = 3, Name = "周八", Age = 25, Description = "周八是一个好学生" },
              new Student() { Id = 7, ClassId = 1, Name = "绿春", Age = 30, Description = "张三是一个好学生" },
             new Student() { Id = 8, ClassId = 1, Name = "麻醉", Age = 35, Description = "李四是一个好学生" },
             new Student() { Id = 9, ClassId = 2, Name = "开天", Age = 26, Description = "王五是一个好学生" },
             new Student() { Id = 10, ClassId = 2, Name = "匹敌", Age = 22, Description = "赵六是一个好学生" },
             new Student() { Id = 11, ClassId = 3, Name = "独轮车", Age = 23, Description = "孙七是一个好学生" },
             new Student() { Id = 12, ClassId = 3, Name = "火箭英", Age = 20, Description = "周八是一个好学生" }

        };
        return students;
    }
}

下面写几个简单的linq的语句,大概知道一下它的使用.


   public void  Show()
   {
       //获取一下ClassID为3的学生们
       List<Student> students = GetStudentsList();
       List<Student> studentsId=students.Where(s => s.ClassId == 3).ToList();
       Console.WriteLine("********************************1*****************************");
       foreach (var student in studentsId)
       {
           Console.WriteLine($"{student.Id} {student.Name} {student.Age} {student.Description}");
       }
       Console.WriteLine("********************************2*****************************");
       //获取一下年纪大于24的学生们
       List<Student> studentsAge = students.Where(s => s.Age > 24).ToList();
       foreach (var student in studentsAge)
       {
           Console.WriteLine($"{student.Id} {student.Name} {student.Age} {student.Description}");
       }
       Console.WriteLine("********************************3*****************************");
       //获取一下年纪小于23同时ClassID为2的学生们
       List<Student> studentsAgeClassId = students.Where(s => s.Age < 23 && s.ClassId == 2).ToList();
       foreach (var student in studentsAgeClassId)
       {
           Console.WriteLine($"{student.Id} {student.Name} {student.Age} {student.Description}");
       }
   }

linq的原理的探究

下面尝试探索一下这个linq的本质是什么?

尝试自己实现一下这3个需求代码,如下

 List<Student> students = GetStudentsList();
 List<Student> studentsId=new List<Student>();
 foreach (var student in students)
 {
     if (student.ClassId == 3)
     {
         studentsId.Add(student);
     }
 }

 List<Student> studentsAge=new List<Student>();
 foreach (var student in students)
 {
     if (student.Age > 24)
     {
         studentsAge.Add(student);
     }
 }

 List<Student> studentsAgeClassId=new List<Student>();
 foreach (var student in students)
 {
     if (student.Age < 23 && student.ClassId == 2)
     {
         studentsAgeClassId.Add(student);
     }
 }

这上面的代码有什么问题?

大量的重复代码---最好是封装一些,把重复的代码统一封装;

这里使用一个解决方案

封装一个方法,将判断的逻辑独立到一个小方法中去.

        public static bool  IsOk01(Student student)
        {
            return student.ClassId == 3;
        }

        public static bool IsOk02(Student student)
        {
            return student.Age > 24;
        }

        public static bool IsOk03(Student student)
        {
            return student.Age < 23 && student.ClassId == 2;
        }
        public static List<Student> DragonWhere(List<Student> studentlist,Func<Student,bool> Func)
        {
            List<Student> students = new List<Student>();
            foreach (var student in studentlist)
            {
                if (Func(student))
                {
                    students.Add(student);
                }
            }
            return students;
        }

尝试使用一下封装的函数

List<Student> students = GetStudentsList();
List<Student> studentId= MethodExtension.DragonWhere(students, MethodExtension.IsOk01);

这里还可以使用扩展方法和lambda表达式进一步的优化我们的代码

      public static List<Student> DragonWhere(this List<Student> studentlist,Func<Student,bool> Func)
        {
            List<Student> students = new List<Student>();
            foreach (var student in studentlist)
            {
                if (Func(student))
                {
                    students.Add(student);
                }
            }
            return students;
        }

在调用的时候,

List<Student> students = GetStudentsList();
List<Student> studentsId = students.DragonWhere(s => s.ClassId == 3);

这里就串联上了我们之前学到的知识,最后为了支持多种数据类型,使用上泛型,最后我们就得到了最终封装的方法.

 public static List<T> DragonWhere<T>(this List<T> studentlist,Func<T,bool> Func)
 {
     List<T> students = new List<T>();
     foreach (var student in studentlist)
     {
         if (Func(student))
         {
             students.Add(student);
         }
     }
     return students;
 }

这里也要使用ilspy反编译一下它们的实现是如何?

看起来跟我们自己实现的Where差不多的样子.
img

再去看一下它的movenext函数.

img

这个就是一个yield return反汇编出来的状态机的代码。

linq常见的语句

 public void Show()
 {
     List<Student> studentList = this.GetStudentList(); 
     #region Linq 扩展方法&表达式
     {  
         var list = studentList.Where<Student>(s => s.Age < 30); //list里面必然是符合要求的数据;
         foreach (var item in list)
         {
             Console.WriteLine("Name={0}  Age={1}", item.Name, item.Age);
         }
     }
     {
         Console.WriteLine("********************");
         var list = from s in studentList
                    where s.Age < 30
                    select s;   //list里面必然是符合要求的数据;

         foreach (var item in list)
         {
             Console.WriteLine("Name={0}  Age={1}", item.Name, item.Age);
         }
     }
     #endregion

     #region linq to object Show
     {
         Console.WriteLine("********************");
         var list = studentList.Where<Student>(s => s.Age < 30)
                              .Select(s => new  //投影:可以做一些自由组装+ new 一个匿名类,也可以new 具体类;
                              {
                                  IdName = s.Id + s.Name,
                                  ClassName = s.ClassId == 2 ? "高级班" : "其他班"
                              });
         foreach (var item in list)
         {
             Console.WriteLine("Name={0}  Age={1}", item.ClassName, item.IdName);
         }
     }
     {
         Console.WriteLine("********************");
         var list = from s in studentList
                    where s.Age < 30
                    select new
                    {
                        IdName = s.Id + s.Name,
                        ClassName = s.ClassId == 2 ? "高级班" : "其他班"
                    };

         foreach (var item in list)
         {
             Console.WriteLine("Name={0}  Age={1}", item.ClassName, item.IdName);
         }
     }
     {
         Console.WriteLine("********************");
         var list = studentList.Where<Student>(s => s.Age < 30)//条件过滤
                              .Select(s => new//投影
                              {
                                  Id = s.Id,
                                  ClassId = s.ClassId,
                                  IdName = s.Id + s.Name,
                                  ClassName = s.ClassId == 2 ? "高级班" : "其他班"
                              })
                              .OrderBy(s => s.Id)//排序 升序
                              .ThenBy(s => s.ClassName) //多重排序,可以多个字段排序都生效
                              .OrderByDescending(s => s.ClassId)//倒排
                              .Skip(2)//跳过几条  //必须要先排序
                              .Take(3)//获取几条 //必须要先排序
                              ;
         foreach (var item in list)
         {
             Console.WriteLine($"Name={item.ClassName}  Age={item.IdName}");
         }
     }
     {//group by·
         Console.WriteLine("********************");
         var list = from s in studentList
                    where s.Age < 30
                    group s by s.ClassId into sg
                    select new
                    {
                        key = sg.Key,
                        maxAge = sg.Max(t => t.Age)
                    };
         foreach (var item in list)
         {
             Console.WriteLine($"key={item.key}  maxAge={item.maxAge}");
         }
         //group by new {s.ClassId,s.Age}
         //group by new {A=s.ClassId>1}
     }
     {
         Console.WriteLine("********************");
         var list = studentList.GroupBy(s => s.ClassId).Select(sg => new
         {
             key = sg.Key,
             maxAge = sg.Max(t => t.Age)
         });
         foreach (var item in list)
         {
             Console.WriteLine($"key={item.key}  maxAge={item.maxAge}");
         }
     }
     {
         var list = studentList.GroupBy(s => s.ClassId);
         foreach (var date in list) ///实现了IEnumerable
         {
             Console.WriteLine(date.Key);

             foreach (var item in date)
             {
                 Console.WriteLine(item.Age);
             }

         }

     }
     List<Class> classList = new List<Class>()
         {
             new Class()
             {
                 Id=1,
                 ClassName="架构班"
             },
             new Class()
             {
                 Id=2,
                 ClassName="高级班"
             },
             new Class()
             {
                 Id=3,
                 ClassName="全栈班"
             },
         };
     {

         //Join 
         var list = from s in studentList
                    join c in classList on s.ClassId equals c.Id  //只能使用equals  不能使==
                    select new
                    {
                        Name = s.Name,
                        CalssName = c.ClassName
                    };
         foreach (var item in list)
         {
             Console.WriteLine($"Name={item.Name},CalssName={item.CalssName}");
         }
     }
     {
         var list = studentList.Join(classList, s => s.ClassId, c => c.Id, (s, c) => new
         {
             Name = s.Name,
             CalssName = c.ClassName
         });
         foreach (var item in list)
         {
             Console.WriteLine($"Name={item.Name},CalssName={item.CalssName}");
         }
     }
     {//左连接
         var list = from s in studentList
                    join c in classList on s.ClassId equals c.Id
                    into scList
                    from sc in scList.DefaultIfEmpty()//
                    select new
                    {
                        Name = s.Name,
                        CalssName = sc == null ? "无班级" : sc.ClassName//c变sc,为空则用
                    };
         foreach (var item in list)
         {
             Console.WriteLine($"Name={item.Name},CalssName={item.CalssName}");
         }
         Console.WriteLine(list.Count());
     }
     {
         var list = studentList.Join(classList, s => s.ClassId, c => c.Id, (s, c) => new
         {
             Name = s.Name,
             CalssName = c.ClassName
         }).DefaultIfEmpty();//为空就没有了
         foreach (var item in list)
         {
             Console.WriteLine($"Name={item.Name},CalssName={item.CalssName}");
         }
         Console.WriteLine(list.Count());
     }
     {

         //左连接和右链接  就是链接对象交换一下即可;

     }
     #endregion
 }