2024年8月

TF-IDF(Term Frequency-Inverse Document Frequency),是用来衡量一个词在文档中的重要性,下面看一下TDF-IDF的公式:

首先是TF,也就是词频,用来衡量一个词在文档中出现频率的指标。假设某词在文档中出现了( n )次,而文档总共包含( N )个词,则该词的TF定义为:

注意:(t,d)中的t表示的是文档中的词汇,d表示的是文档的词汇集合,通过计算TF也就是进行词频率的统计,好的,那么看一下代码的实现。

defcompute_tf(word_dict, doc_words):""":param word_dict: 字符的统计个数
:param doc_words: 文档中的字符集合
:return:
"""tf_dict={}
words_len
=len(doc_words)for word_i, count_i inword_dict.items():
tf_dict[word_i]
= count_i /words_lenreturntf_dict#示例文档 doc1 = "this is a sample"doc2= "this is another example example example"doc3= "this is a different example example" #分割单词 doc1_words =doc1.split()
doc2_words
=doc2.split()
doc3_words
=doc3.split()#计算每个文档的词频 word_dict1 =Counter(doc1_words)
word_dict2
=Counter(doc2_words)
word_dict3
=Counter(doc3_words)#计算TF tf1 =compute_tf(word_dict1, doc1_words)
tf2
=compute_tf(word_dict2, doc2_words)
tf3
=compute_tf(word_dict3, doc3_words)print(f'tf1:{tf1}')print(f'tf2:{tf2}')print(f'tf3:{tf3}')#tf1:{'this': 0.25, 'is': 0.25, 'a': 0.25, 'sample': 0.25}#tf2:{'this': 0.16666666666666666, 'is': 0.16666666666666666, 'another': 0.16666666666666666, 'example': 0.5}#tf3:{'this': 0.16666666666666666, 'is': 0.16666666666666666, 'a': 0.16666666666666666, 'different': 0.16666666666666666, 'example': 0.3333333333333333}

看完TF的计算之后,我们看一下IDF的定义,公式和对应的实现吧,IDF的定义是:即逆文档频率,反映了词的稀有程度,IDF越高,说明词越稀有。这个逆文档频率也就是说一个词的文档集合中出现的次数越少,他就越具有表征型,因为在文中有很多“的”,“了”这种词,这些词重要性不大,反而出现少的词重要性大一点,来看一下IDF的公式:

其中,( D )是文档总数,( df_t )是包含词( t )的文档数量。通过取对数,可以避免数值过大的问题,同时保证了IDF的单调递减特性,下面看一下代码的现实:

defcompute_idf(doc_list):""":param doc_list: 文档的集合
:return:
"""sum_list= list(set([word_i for doc_i in doc_list for word_i indoc_i]))

idf_dict
= {word_i: 0 for word_i insum_list}for word_j insum_list:for doc_j indoc_list:if word_j indoc_j:
idf_dict[word_j]
+= 1 return {k: math.log(len(doc_list) / (v + 1)) for k, v inidf_dict.items()}#示例文档 doc1 = "this is a sample"doc2= "this is another example example example"doc3= "this is a different example example" #分割单词 doc1_words =doc1.split()
doc2_words
=doc2.split()
doc3_words
=doc3.split()#计算每个文档的词频 word_dict1 =Counter(doc1_words)
word_dict2
=Counter(doc2_words)
word_dict3
=Counter(doc3_words)#计算整个文档集合的IDF idf =compute_idf([doc1_words, doc2_words, doc3_words])#idf:{'different': 0.4054651081081644, 'another': 0.4054651081081644, 'a': 0.0, 'example': 0.0, 'this': -0.2876820724517809, 'sample': 0.4054651081081644, 'is': -0.2876820724517809}

通过结果可以发现,different、another和sample都比is、a等词汇的IDF值要高,代表越重要。

好的,最后看一下TF-IDF的公式吧,

$$TF-IDF=TF*IDF  $$

TF-IDF 就是TF*IDF,来综合的评价一个词在文档中的重要性。

最后看一下完整的代码,

importmathfrom collections importCounterimportmathdefcompute_tfidf(tf_dict, idf_dict):
tfidf
={}for word, tf_value intf_dict.items():
tfidf[word]
= tf_value *idf_dict[word]returntfidfdefcompute_tf(word_dict, doc_words):""":param word_dict: 字符的统计个数
:param doc_words: 文档中的字符集合
:return:
"""tf_dict={}
words_len
=len(doc_words)for word_i, count_i inword_dict.items():
tf_dict[word_i]
= count_i /words_lenreturntf_dictdefcompute_idf(doc_list):""":param doc_list: 文档的集合
:return:
"""sum_list= list(set([word_i for doc_i in doc_list for word_i indoc_i]))

idf_dict
= {word_i: 0 for word_i insum_list}for word_j insum_list:for doc_j indoc_list:if word_j indoc_j:
idf_dict[word_j]
+= 1 return {k: math.log(len(doc_list) / (v + 1)) for k, v inidf_dict.items()}#示例文档 doc1 = "this is a sample"doc2= "this is another example example example"doc3= "this is a different example example" #分割单词 doc1_words =doc1.split()
doc2_words
=doc2.split()
doc3_words
=doc3.split()#计算每个文档的词频 word_dict1 =Counter(doc1_words)
word_dict2
=Counter(doc2_words)
word_dict3
=Counter(doc3_words)#计算TF tf1 =compute_tf(word_dict1, doc1_words)
tf2
=compute_tf(word_dict2, doc2_words)
tf3
=compute_tf(word_dict3, doc3_words)print(f'tf1:{tf1}')print(f'tf2:{tf2}')print(f'tf3:{tf3}')#计算整个文档集合的IDF idf =compute_idf([doc1_words, doc2_words, doc3_words])print(f'idf:{idf}')#计算每个文档的TF-IDF tfidf1 =compute_tfidf(tf1, idf)
tfidf2
=compute_tfidf(tf2, idf)
tfidf3
=compute_tfidf(tf3, idf)print("TF-IDF for Document 1:", tfidf1)print("TF-IDF for Document 2:", tfidf2)print("TF-IDF for Document 3:", tfidf3)"""tf1:{'this': 0.25, 'is': 0.25, 'a': 0.25, 'sample': 0.25}
tf2:{'this': 0.16666666666666666, 'is': 0.16666666666666666, 'another': 0.16666666666666666, 'example': 0.5}
tf3:{'this': 0.16666666666666666, 'is': 0.16666666666666666, 'a': 0.16666666666666666, 'different': 0.16666666666666666, 'example': 0.3333333333333333}
idf:{'example': 0.0, 'different': 0.4054651081081644, 'this': -0.2876820724517809, 'another': 0.4054651081081644, 'is': -0.2876820724517809, 'a': 0.0, 'sample': 0.4054651081081644}
TF-IDF for Document 1: {'this': -0.07192051811294523, 'is': -0.07192051811294523, 'a': 0.0, 'sample': 0.1013662770270411}
TF-IDF for Document 2: {'this': -0.047947012075296815, 'is': -0.047947012075296815, 'another': 0.06757751801802739, 'example': 0.0}
TF-IDF for Document 3: {'this': -0.047947012075296815, 'is': -0.047947012075296815, 'a': 0.0, 'different': 0.06757751801802739, 'example': 0.0}
"""

本文作者:李精卫
更多技术交流、求职机会,欢迎关注
字节跳动数据平台
微信公众号

回复【1】
进入官方交流群

背景

随着抖音集团内部对流式任务的需求不断增长,Flink SQL作为一种低成本接入手段,已经在内部多个方向上得到大规模应用。目前,流式 SQL 任务的规模已经超过3万,任务资源使用量和分配量也达到了百万core。
在降本增效的大背景下,为了解决资源紧缺的问题,并同时满足业务对更高性能的需求,流式计算团队对 FlinkSQL 进行了深度优化,本文将聚焦这一实践,详解主要优化思路。

Engine优化

查询优化

View Reuse

在流式 SQL 中,为增加 SQL 代码的可读性,通常会将通用的计算逻辑放在 view 中。在这里,view 只是一个逻辑概念:在底层实现时,并没有真实存储中的 view 与之对应。
如下图所示,场景一表示任务中存在多个 sink 的表,view 中是窗口聚合运算的逻辑。场景二表示任务需要对两个流进行 union,view 中是普通聚合运算的逻辑。
在这两种场景中,用户会定义一个通用的 view 来进行计算。因为下游不同分支对 view 的查询不同,view 中的计算逻辑会在不同算子中重复计算,由此带来了重复的资源开销。那么问题就在于:为什么 view 没有被复用?
在 Calcite 的原有逻辑中,view 中包含的 Query 会被立即转化成一颗关系表达式树。如果有多条Query 访问了同一个 view,那么就会获得多颗属性完全相同,但分属于不同 Java 对象的 RelNode Tree。因此,后续所有优化都是基于不同的子树对象分别进行的,无法再重新合并成同一棵树。
  • Multi-sink
多 sink 的场景下,在生成 logical plan 阶段时,view 会被 Calcite 转换为多个 RelNode Tree。在后续 optimizer 的子图划分中,这些 RelNode Tree 不会被划分到相同子图中,从而导致 view 不能被复用。
由此可以看出,解决问题需要分别从 Calcite 和 Flink 入手。在Calcite 的 SqlToRel Convert中,不应立即将 view 中的 Query 转化成对应的 RelNode Tree,而应直接返回包含了对应 Sql CatalogView Table 的 LogicalTableScan。
在Flink中,CatalogView 的实现需要将 LogicalTableScan 对象存储下来,让下游节点都引用同一个 CatalogView。在优化之前,将 LogicalTableScan 中的 view 展开成 RelNode Tree,以便下游节点能够引用相同的 RelNode Tree 对象。
  • Union all
在 Union all 场景中,为了复用 view,可以在 view 后面增加一个虚拟的 sink 节点,将 Union all 场景转换为多 sink 场景。这可以使 view 在 logical plan 阶段时,不会提前展开成 RelNode Tree,而 union 也能够引用到相同的 View 对象。虚拟的 sink 节点则会在子图划分后被删除。
从上述两个场景中可知,在进行了 view 复用优化后,view 对应的计算逻辑只需计算一次,整体 CPU 收益为20%。

Remove Redundant Streaming Shuffle

Remove Redundant Streaming Shuffle 可以移除流式场景下不必要的数据分发开销。在批式场景中, shuffle 操作会有落盘的性能开销,这已经在社区中得到了优化。而在流式场景中,shuffle 操作则有序列化和网络传输的开销。
如下图例子所示,在计算不同品类产品价格 Top5 的平均值时,使用了排序和聚合计算。在排序和聚合前对 id 进行了 hash,这说明两个算子有相同的 hash key。数据被 rank 算子 hash 后,就不需要再进行第二次 hash 了,这说明第二个 shuffle 是多余的。
shuffle 是在生成 physical plan 的阶段中产生的。下图展示了 Sql 优化器将 SqlNode 从逻辑节点转换为物理节点的过程,在这个过程中,shuffle 也就是 exchange。转换过程是通过规则进行的,在 relRule.Convert 过程中会遍历每一个逻辑节点,判断当前节点是否满足转换规则,如果存在不满足的情况,就会增加一个 AbstractConvert。
在生成 Exchange 的规则中,会判断当前节点的数据分布特征是否满足需求,如果不满足,就在节点上游增加 Exchange 节点来满足数据分布的特征。最后,PhysicalExchange 会被转换为 hashShuffle,用于数据的分发。
如何移除掉多余的 Streaming Shuffle?针对该问题,主要思路是参考 Batch 对 Shuffle 的优化。在规则转换的过程中,不仅要考虑节点本身,还要考虑输入节点的特征是否满足需求,将问题往上抛。
实现针对 Physical RelNode 的规则判断方法,主要分为以下两种情况:
  • 对于本身没有数据分布特征的节(如 Calc 和 Correlate Node),判断它们能否满足一个特定数据分布的需求,只需检查自身输入中是否包含 hash key。
  • 对于本身有数据分布特征的节点(如 Aggregate 和 Rank nodes),需要确认本身的数据分布特征是否满足给定的 distribution requirements。


    如下图所示,首先要检查 aggregate 节点是否满足数据分布特征,这需要查看它的输入,即 rank 节点是否满足要求。如果 rank 节点不满足,则需要在其上游添加 exchange 节点。添加后,rank 算子满足了数据分布特征。由于 rank 和 aggregate 的 hashkey 相同,因此 aggreagte 也满足了。

该方法可为火山模型提供更优、成本更低的执行计划。火山模型最终将选择这个移除多余 Exchange 的执行计划。移除多余的 streaming shuffle 后,rank 算子和 agg 算子中的 hash 连接已经消失,并且 chain 在一起,整体 CPU 收益达到了 24%。这也为在 Streaming 场景下优化 MultipleInput 的算子提供了可能。

查询执行优化

Streaming MultipleInput Operator

基于 Remove Streaming Shuffle,在对多余的 hash shuffle 进行优化的前提下,可以在 join+join、 join+agg、join+union 中,对shuffle 进行更深层的优化。
如下图所示,因为 agg1 hash key 和 Join left key 相同;agg2 hash key 和 Join right key 相同,所以可将 Join 前的 hash 变为 forward。
当前的 OperatorChain 策略不支持多input算子的 Chain,无法避免因多余 shuffle 而导致的序列化、反序列化和可能的网络开销。因此,流式计算团队使用 MultipleInput 机制,在 Streaming 场景下,将多个 Input 的算子上下游合并为 MultipleInutOperator,从而进行优化。
具体而言,优化经历了以下几个步骤:
  1. 首先,在 Planner 层构建出 MultipleInputExecNode。

    1. MultipleInputExecNode 是在 logical physical 计划后,当 plan 被转换为ExecNode DAG时,从 ExecNodeDAG 中推导而出。获得 ExecNodeDAG 后,先从根节点进行广度优先搜索,从而获取图的拓扑排序。构建 MultipleInputExecNode 是在 Covert ExecNode DAG 环节进行的,完成这一系列操作后,它将在 ExecNode Graph 中构建出来。
  1. 在生成 StreamMultipleInputExecNode 后,被 translate 成 StreamMultipleInput transformation。

    1. 在 transformation 中,包含了创建 MultipleInput Operator 的一些信息,通过 TableOperatorWrapper 存储 sub-op 信息。
  1. 生成 Job Graph。这需要满足以下2个条件:

    1. StreamConfig 需要兼容 Multiple Input 从 two Input 的 TypeSerializer1,2变成 TypeSerializer[],这主要用于 state/key 数据传输。
    2. Stream Graph 可以添加 MultipleInputOperator 节点,通过方法 addMultipleInputOperator,将 Transformation 对应的 properties 添加到 vertex 中构成 Stream Graph 中的节点。
运行时实现了 StreamingMultipleInputOperator ,且需要考虑算子的创建,算子的数据处理,状态,Timer&&Watermark,Barrier,Checkpoint 等问题。
  • Operator initialization:

    • 不只要创建 StreamingMultipleInputOperator,也要创建对应的 sub-op;
    • sub-op 本质上是 Abstract StreamOperator,sub-op id = op id + index;
    • 在 createAllOperator 创建每个 sub-op 对象,并构建 DAG 的输入输出。
  • ProcessElement :

      • 处理数据过程中要保证 key 的传递。

  • State

      • MultipleInputStreamOperato 和 sub-op 分享state handler;
      • 创建新的 API stateNameContext 来解决状态名字冲突。

  • Timer && Watermark

    • MultipleInputStreamOperator和sub-op 分享 timeServiceManager;
    • 创建新的 api TimerNameContext来解决状态名字冲突;
    • timeServiceManager 以 sub-op粒度管理 timer;
    • 使用Combindedwatermark 来保证 Watermark 对齐。
  • barrier:

    此处无需过多考虑,MultipleInput Operator 内部没有 buffer 中的数据,因此按照拓扑顺序进行 checkpoint 不会丢失数据。但需要注意的是,需要将 prepareSnapshotPreBarrier 从 MultipleInputStreamOperator 传播到所有子算子。

经过优化后, agg+join 操作会被合并到 MultipleInput 算子中,这将带来10%的 Cpu 收益,同时也会解决网络内存不足导致任务无法启动的问题

Optimization of Long Sliding Windows

  • 长滑动窗口及其底层实现逻辑
在 Flink SQL 中,长滑动窗口的具体写法是 Hop(table, slide, size)。其中,size 表示窗口的大小,slide 表示窗口移动的步长。在滑动窗口中,如果步长小于窗口大小,那么会有元素属于不同的窗口。
在滑动窗口计算中,如果窗口时间周期长,在大流量场景下计算7天、30天等时间段的uv并进行去重的操作时,会出现计算中数据延迟特别严重,甚至数据无法推动的问题,即便增加资源也无法解决这一问题。
经过对滑动窗口底层实现逻辑的分析,可知滑动窗口计算的主要性能瓶颈在于窗口计算最小的单位——窗格(pane)的合并操作。pane 是窗口大小和步长的最大公约数,大多数时候,pane 的大小都是 1。每次滑动窗口触发计算时,均需要把当前窗口下对应的所有窗格数据重新合并一遍。由于长窗口下其窗格数量很多,所以性能开销很大。
  • 长滑动窗口优化思路
对此,主要的优化思路是以空间换时间:
  1. 在窗口算子中定义全局状态,存储当前窗口的计算结果;
  2. 在聚合函数中新增 retractMerge 方法,窗口向后滑动时,移除被划走窗口的数据;
  3. 触发下一次计算时,合并新增窗口的数据。
如下图所示:在窗口向后滑动 3 个窗格时,移除 pane1-pane3 的结果,再合并进来 pane 11-pane13 的结果。总共需要计算6个窗格,优化了4个窗格的计算。
因此,当窗口大小和滑动步长的比值越大,优化效果就越明显。优化后,整体 CPU 收益达到了 60%。

数据处理(Format侧)

Native Json Format

目前,抖音集团公司内部约有1.3万个任务使用 Json Format,占用资源近 70 万core。如果按照 5%的占比进行保守估计,线上约有3.5万core用于 Json 的反序列化,因此该部分有较大优化空间。
下图展示了数据从消息队列(MQ)中读取,并最终传递给下游运算符的主要流程。其中,Json 反序列化和将 GeneralRowData 序列化为字节,是两个重要的开销。
针对上述两项重要的资源消耗,主要从以下两个方面进行优化:
  • 针对 Json 反序列化开销

    使用支持向量化编程的 c++ json 解析库, 选择字节内部自研的 sonic-cpp,来提高性能。

  • 针对序列化为 binaryRowData 的开销

    使用 native 方法直接产出 BinaryRowData 所需要的二进制表示,再使用 BinaryRowData 指向这一部分数据,从而免去序列化对应的开销。
在测试集中,native Json 的 CPU 收益能够达到 57%。

优化实践

为了确保引擎优化能够给业务方带来实际的优化效果,流式计算团队在内部做了大量工作,以确保优化项能够稳定上线,以下将对此展开详细介绍。
  • 工具层
如上框架图所示,最下层是工具层,具备以下5项能力:

a. 支持 SQL 任务元信息实时上报;

b. 算子粒度离线数仓,提供算子粒度的任务监控;

c. Commits 粒度 DAG 兼容性检查 :可以提前发现哪些优化项会影响任务状态恢复;

d. 优化项分优先级灰度:可以限制风险暴露范围;

e. 数据准确性链路构建:保证了上线优化项不会导致数据准确性发现问题。

基于上述能力,工具层实现了算子粒度的任务监控,同时保证了任务稳定性和数据准确性
  • 优化层
在优化项这层,对存量优化进行推广上量或全量,同时也对很多新增优化项展开探索和推广。
  • 引擎&平台层
在引擎&平台层,与业务方协作,推动存量任务治理。通过在平台侧进行优化项配置,使新增作业能够直接应用某些优化项。同时,经过校验的优化项将在引擎侧中默认开启。
经过优化,最终获得了 10w core+ 的性能收益。

未来展望

在未来,流式计算团队将持续优化 FlinkSQL,探索 Join 中状态的最佳使用方式。同时,也会在流批融合 native Engine 等方向上持续探索发力。
点击跳转
火山引擎Flink流式计算
了解更多

前言

想要快速了解物联网的世界吗?如果你对物联网(IoT)感兴趣,或者正打算开发自己的物联网项目。可以试试
IoTSharp
,一个基于 .NET 的开源平台。

无论你是初学者还是有经验的大佬,IoTSharp 提供了丰富的功能和广泛的协议支持。让物联网项目开发变得简单又直观。它不仅功能全面,而且操作灵活,让你可以快速上手,轻松实现你的物联网梦想,快来试一试 IoTSharp。

项目介绍

为什么会有 IoTSharp?

想一下,你想要创建一个智能家居系统,比如让家里的灯可以通过手机控制开关,或者让空调可以根据你的习惯自动调节温度。但当开始做这些事情的时候,可能会遇到很多难题,比如怎么让设备互相通信,如何处理大量的数据,怎样确保系统的安全性等等。这些问题可能让你感到头疼。

IoTSharp 能做什么?

IoTSharp 的出现就是为了帮助解决这些问题。它是一个现成的平台,可以帮助你轻松地将不同的设备连接起来,并且能够处理这些设备产生的大量数据。最重要的是开源的,可以和大家一起讨论并且有社区支持。

IoTSharp 的亮点

简单易用
:即使你是新手,也能快速上手。

跨平台
:无论是在 Windows 还是 Linux 或 Mac 上都能运行。

多种设备支持
:支持常见的物联网通信协议,让不同设备轻松接入。

安全可靠
:内置的安全措施确保你的数据安全无忧。

IoTSharp 是一个开源的物联网基础平台,集设备属性数据管理、遥测数据监测、RPC多模式远程控制、规则链设计引擎等强大能力,依据数字孪生概念将可见与不可见的物理设备统一孪生到数字世界,在落地上IoTSharp结合了资产管理、产品化发展的理念,让平台应用更加贴合复杂的应用场景,在协议支持上支持HTTP、MQTT 、CoAp 等多种标准物联网协议接入和非标协议的转换。

项目技术

1、
编程语言

主要使用 C# 和 .NET 进行后端开发。

2、
系统框架

前端使用 Vue 3,后端基于.NET 8.0 + WebAPI。

3、
数据库支持

支持多种数据库类型,包括:

关系型数据库
:PostgreSQL、 MySQL、SQL Server 等。

时序数据库
:InfluxDB、IoTDB、TDengine、TimescaleDB、PinusDB 等,以满足不同类型的数据存储需求。

4、
消息队列与 EventBus

支持多种消息队列和 EventBus 系统,如 RabbitMQ、Kafka、 ZeroMQ、NATS、Pulsar、Redis Streams、Amazon SQS、Azure Service Bus 等,用于构建事件驱动架构和实现高效的消息传递

5、
EventBus 存储

支持将事件存储在多种数据存储中,如 PostgreSql、MongoDB、InMemory、LiteDB、MySql、SqlServer 等。

项目使用

本次介绍的是Windows操作系统下的IoTSharp部署方法。对于其他平台,大家可以访问
IoTSharp文档
获取更多部署信息。

对于Windows环境下的部署,我们采用的是轻量级的 Sqlite 作为数据存储解决方案。

下面是详细的部署步骤:

1、下载

首先在Github 或者 Gitee 中下载最新版本的安装包, Windows安装包名为
IoTSharp.Release.win7-x64.zip
下载至本地。

2、启动

解压压缩包后, 我们可以看到里面 有一个 IoTSharp 的Exe文件, 双击运行即可启动。

可以看到控制台启动, 启动后, 即可在浏览器中打开
http://localhost:2927
来访问。

3、注册服务

IoTSharp 已经支持了Windows服务的方式运行, 如果有需要注册为Widnows服务,

需要首先了解
https://docs.microsoft.com/zh-cn/windows-server/administration/windows-commands/sc-create

使用 sc 命令创建 为 Windows 服务,然后打开
http://localhost:2927
来访问。

看到下图说明运行成功

4、初始化influxdb

浏览器访问
http://localhost:8086/
,初始化influxdb

Org: `iotsharp` Bucket: `iotsharp-bucket`

5、注册

Chrome浏览器访问
http://localhost:2927/

6、运行

注册完成后,可以进入首页,具体如下所示:

首页

设备管理

产品列表

项目地址

总结

IoTSharp 是一个 基于.NET 开源的物联网基础平台, 支持 HTTP、MQTT 、CoAp 协议, 属性数据和遥测数据协议简单类型丰富,是一个用于数据收集、处理、可视化与设备管理的 IoT 平台。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!

Vite 是一个前端构建工具,它以其快速的开发服务器和生产优化的打包器而闻名前端界,今天的内容,必须得唠唠 Vite 的关键能力,以下是 Vite 的核心组件分析,以及使用案例:

  1. 原理分析


    • Vite 利用了现代浏览器对
      ESModule
      语法的支持,在开发环境中不进行打包编译,而是通过启动本地
      devServer
      来提供服务,从而显著提高了启动速度 。
    • 在开发过程中,Vite 按需加载页面所需的模块,而不是一次性编译整个项目 。
    • Vite 使用
      esbuild
      进行依赖预构建,将多种模块化规范转换为 ESM,并减少网络请求 。
    • Vite 通过 HTTP 缓存和文件系统缓存优化性能,利用
      es-module-lexer

      magic-string
      重写模块路径 。
  2. 源码实现


    • Vite 的源码主要分为客户端和服务端两部分,客户端代码负责处理 WebSocket 消息,服务端代码处理代码构建和模块请求 。
    • Vite 的服务端使用 Koa 框架,通过拦截 HTTP 请求来提供模块服务 。
  3. 使用技巧


    • 利用 Vite 的快速开发体验,可以快速启动项目并实时预览修改效果 。
    • 由于 Vite 支持原生 ESM,可以直接在浏览器中运行未打包的代码,简化了开发流程 。
    • Vite 的配置简单,通常只需要一个配置文件即可完成项目的构建和部署 。
    • Vite 支持多种前端框架和语言,可以灵活地选择技术栈 。
  4. Vite 相对于 Webpack 的优势


    • Vite 提供了更快的冷启动和热模块替换 。
    • Vite 支持原生 ESM,无需额外的转译和打包步骤 。
    • Vite 实现了按需加载,降低了构建和加载的成本 。
    • Vite 提供了易配置和零配置选项,简化了项目设置 。

Vite 的设计哲学是尽可能利用现代浏览器的能力,减少不必要的打包操作,从而提高开发效率。在生产环境中,Vite 使用 Rollup 进行打包,确保了最终产物的优化和性能 。

Vite 的核心组件功能主要包括以下几个方面:

  1. 开发服务器(Dev Server)


    • Vite 提供了一个快速的开发服务器,利用 HTTP/2 和 ES 模块(ESM)来提供服务,实现快速的模块加载和热更新。
  2. 按需编译


    • Vite 在开发模式下按需编译请求的模块,而不是一次性编译整个项目,这大大提升了开发时的响应速度。
  3. 依赖预构建


    • Vite 使用
      esbuild
      预先构建依赖,将 CommonJS、UMD 等模块化规范转换为 ESM,减少网络请求次数,提高性能。
  4. 热模块替换(HMR)


    • Vite 实现了高效的热模块替换机制,当源代码发生变化时,只替换变更的部分,而不需要重新加载整个页面。
  5. 缓存机制


    • Vite 利用 HTTP 缓存和文件系统缓存来提高性能,对于不经常变动的依赖使用强缓存,源码部分使用协商缓存。
  6. 模块路径重写


    • Vite 使用
      es-module-lexer

      magic-string
      来解析和重写模块路径,以适应浏览器的模块加载机制。
  7. 构建优化


    • 在生产模式下,Vite 使用 Rollup 或其他构建工具来打包应用,生成优化后的静态资源。
  8. 插件系统


    • Vite 支持插件扩展,允许开发者通过插件来自定义构建流程,例如添加对特定类型文件的支持。

下面 V 哥将一一介绍这些核心功能并结合案例讲解。

1. 开发服务器(Dev Server)

开发服务器(Dev Server)是 Vite 的核心组件之一,它在开发过程中提供了快速的模块加载和热更新功能。以下是开发服务器的一个简单案例和解析:

来看一个案例

假设我们有一个简单的 Vue 3 项目,我们希望使用 Vite 来启动开发服务器。以下是项目的目录结构和关键文件:

/my-vue-app
|-- node_modules
|-- public
|   |-- index.html
|-- src
|   |-- main.js
|   |-- App.vue
|-- vite.config.js
|-- package.json
  1. index.html
    (
    public/index.html
    ):
   <!DOCTYPE html>
   <html lang="en">
   <head>
     <meta charset="UTF-8">
     <title>Vite Vue App</title>
   </head>
   <body>
     <div id="app"></div>
     <script type="module" src="/src/main.js"></script>
   </body>
   </html>
  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';

   const app = createApp(App);
   app.mount('#app');
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div>
       <h1>{{ message }}</h1>
     </div>
   </template>

   <script setup>
   const message = 'Hello Vite!';
   </script>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // 配置选项...
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

这个命令会执行
package.json
中定义的
"dev"
脚本,使用 Vite 启动开发服务器。

具体解析一下

  1. 服务启动


    • 当执行
      npm run dev
      时,Vite 会启动一个本地开发服务器。
  2. 模块服务


    • 开发服务器会监听文件系统的变化,并为
      index.html
      中引用的
      type="module"
      脚本提供服务。
  3. 按需加载


    • 当浏览器请求
      /src/main.js
      时,Vite 服务器会提供该文件的内容,并按需解析和加载依赖的模块(如
      App.vue
      )。
  4. 热更新(HMR)


    • 如果
      App.vue
      或其他依赖文件发生变化,Vite 会通过 WebSocket 推送更新到浏览器,实现热更新,而无需刷新页面。
  5. 源码映射(Sourcemap)


    • Vite 可以生成 Sourcemap,使得在浏览器的开发者工具中可以直接调试原始源代码,而不是转换后的代码。
  6. 开发与生产分离


    • 开发服务器专注于开发体验,而生产构建会使用 Rollup 或其他配置进行优化,确保生产环境的性能。

2. 按需编译

按需编译是 Vite 的一个核心特性,它允许开发者在开发过程中只编译那些被实际请求的模块,而不是整个项目。以下是使用 Vite 进行按需编译的案例和解析:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- public/
|-- src/
|   |-- main.js
|   |-- App.vue
|   |-- SomeComponent.vue
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';

   const app = createApp(App);
   app.mount('#app');
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div>
       <h1>Home Page</h1>
       <button @click="loadComponent">Load Component</button>
     </div>
   </template>

   <script>
   import SomeComponent from './SomeComponent.vue';

   export default {
     methods: {
       loadComponent() {
         this.$forceUpdate();
         this.$options.components.SomeComponent = SomeComponent;
       }
     }
   }
   </script>
  1. SomeComponent.vue
    (
    src/SomeComponent.vue
    ):
   <template>
     <div>
       <h2>I'm a lazy-loaded component!</h2>
     </div>
   </template>

   <script>
   export default {
     name: 'SomeComponent'
   }
   </script>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // 配置选项...
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

按需编译解析

  1. 首次加载


    • 当你访问应用的主页时,只有
      main.js

      App.vue
      会被加载和编译,因为它们是初始入口点。
  2. 按需加载组件


    • 当用户点击按钮触发
      loadComponent
      方法时,
      SomeComponent.vue
      将被按需加载。由于 Vite 支持 ES 模块,这个组件将通过动态
      import()
      语法异步加载。
  3. 编译请求的模块



    • SomeComponent.vue
      被请求时,Vite 的开发服务器会捕获这个请求,并编译该模块,然后将其发送给浏览器。
  4. 热更新(HMR)


    • 如果
      SomeComponent.vue
      在开发过程中被修改,Vite 将通过 HMR 更新浏览器中的组件,而不需要重新加载整个页面。
  5. 优化开发体验


    • 按需编译减少了初次加载的时间和资源消耗,同时保持了快速的模块更新和热替换能力。

一句话,Vite 通过按需编译,从而提供更快速的开发体验和更高效的资源管理。

3. 依赖预构建

依赖预构建是 Vite 的一个重要特性,它在项目启动之前预先处理项目依赖,将非 ES 模块转换为 ES 模块,以减少开发时的模块请求次数和提高性能。以下是依赖预构建的案例和解析:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|   |-- some-pkg/
|       |-- package.json
|       |-- index.js
|-- src/
|   |-- main.js
|   |-- App.vue
|-- vite.config.js
|-- package.json

文件内容

  1. index.js
    (
    node_modules/some-pkg/index.js
    - 一个 CommonJS 模块):
   // CommonJS 模块
   exports.default = function() {
     console.log('CommonJS package loaded');
   };
  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';
   import somePkg from 'some-pkg';

   somePkg.default(); // 使用预构建的依赖

   const app = createApp(App);
   app.mount('#app');
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div>
       <h1>App Component</h1>
     </div>
   </template>

   <script>
   export default {
     name: 'App'
   }
   </script>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // 配置选项,例如别名 @ 指向 src 目录
     alias: {
       '@': '/src',
     },
     // 其他配置...
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0",
       "some-pkg": "1.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

依赖预构建过程解析

  1. 预构建过程


    • 当 Vite 启动时,它会检查
      node_modules
      目录中的依赖,并使用
      esbuild
      这个高性能的 JavaScript 打包器来预构建这些依赖。
  2. 转换模块类型


    • 如果依赖是 CommonJS 或其他非 ES 模块类型,
      esbuild
      会将其转换为 ES 模块,以便浏览器能够通过
      import
      语句加载。
  3. 减少请求次数


    • 通过预构建,Vite 可以将多个小的依赖模块合并为一个大的模块,从而减少 HTTP 请求次数,加快页面加载速度。
  4. 缓存优化


    • 预构建的依赖会被缓存到
      node_modules/.vite
      目录中,这样在开发过程中,只有当依赖发生变化时,才会重新构建,否则直接使用缓存。
  5. 兼容性


    • 预构建确保了即使在不支持原生 ES 模块的旧版浏览器环境中,依赖也能被正确加载和执行。
  6. 开发服务器启动


    • 一旦预构建完成,Vite 将启动开发服务器,提供源代码和预构建依赖的服务。

小结一下,Vite 能够显著提高大型项目的开发效率和性能,同时确保了代码的兼容性和模块的按需加载。

4. 热模块替换(HMR)

热模块替换(Hot Module Replacement)是 Vite 在开发过程中提供的一项功能,它允许开发者在不刷新整个浏览器页面的情况下,替换、添加或删除模块。下面是 HMR 案例和解析,一起来看看:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- public/
|-- src/
|   |-- main.js
|   |-- Button.vue
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import Button from './Button.vue';

   const app = createApp();
   app.component('button-component', Button);
   app.mount('#app');
  1. Button.vue
    (
    src/Button.vue
    ):
   <template>
     <button @click="count++">Count is: {{ count }}</button>
   </template>

   <script setup>
   import { ref } from 'vue';

   const count = ref(0);
   </script>
  1. index.html
    (
    public/index.html
    ):
   <!DOCTYPE html>
   <html lang="en">
   <head>
     <meta charset="UTF-8">
     <title>Vite HMR Demo</title>
   </head>
   <body>
     <div id="app">
       <button-component></button-component>
     </div>
     <script type="module" src="/src/main.js"></script>
   </body>
   </html>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // HMR 默认开启,无需额外配置
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

HMR 过程解析

  1. 开发服务器启动


    • 当你启动 Vite 时,开发服务器会启动,并自动开启 HMR 功能。
  2. 修改 Button 组件


    • 假设你正在开发过程中修改了
      Button.vue
      的模板或脚本。
  3. HMR 替换模块


    • 保存文件后,Vite 会编译修改的
      Button.vue
      组件,并使用 HMR 更新浏览器中的对应模块,而不需要重新加载整个页面。
  4. 状态保持


    • 由于 HMR 的特性,
      Button.vue
      中的
      count
      状态会保持不变,即使组件被重新加载。用户会看到按钮上显示的计数在点击后继续增加,而页面不会刷新。
  5. 浏览器开发者工具


    • 在浏览器的开发者工具中,你可以看到网络请求只针对修改的模块,而不是整个资源。

小结一下,我们可以看到 Vite 的 HMR 功能使得开发更加高效,允许开发者快速迭代组件,同时保持应用状态和用户体验。

5. 缓存机制

缓存机制在 Vite 中主要体现在两个方面:
HTTP 缓存

文件系统缓存
。以下是缓存机制的案例和解析:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- public/
|-- src/
|   |-- main.js
|   |-- App.vue
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';

   createApp(App).mount('#app');
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div>
       <h1>{{ message }}</h1>
     </div>
   </template>

   <script>
   export default {
     data() {
       return {
         message: 'Hello, Vite!'
       };
     }
   }
   </script>
  1. index.html
    (
    public/index.html
    ):
   <!DOCTYPE html>
   <html lang="en">
   <head>
     <meta charset="UTF-8">
     <title>Vite Cache Demo</title>
   </head>
   <body>
     <div id="app"></div>
     <script type="module" src="/src/main.js"></script>
   </body>
   </html>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // 配置选项...
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

缓存机制解析

  1. HTTP 缓存


    • Vite 为静态资源设置了 HTTP 缓存头。对于不经常变动的资源(如预构建的依赖),Vite 使用
      Cache-Control
      响应头,指定资源可以被浏览器缓存。
  2. 文件系统缓存


    • Vite 会在
      node_modules/.vite
      目录下缓存预构建的依赖。当没有发生变化时,Vite 会直接从这个缓存中读取依赖,避免重复构建。
  3. 示例操作


    • 假设你修改了
      App.vue
      中的
      message
      数据。保存文件后,Vite 会触发 HMR 更新,但不会重新构建整个项目的依赖。
  4. 浏览器缓存利用


    • 当你访问开发服务器提供的资源时(如
      main.js
      ),浏览器会根据 HTTP 响应头中的缓存指令缓存这些资源。下次访问相同的资源时,如果资源没有更新,浏览器会使用本地缓存,而不是从服务器重新下载。
  5. 协商缓存


    • 对于源码文件,Vite 使用 HTTP 协商缓存(如
      ETag

      Last-Modified
      )。当源文件有更新时,Vite 会更新这些标记,浏览器会根据这些标记判断是否需要从服务器获取新的资源。
  6. 开发体验优化


    • 通过缓存机制,Vite 减少了开发过程中的网络传输和重复构建,提高了开发服务器的响应速度和开发体验。

Vite 的缓存机制帮助提高开发效率的同时,减少不必要的资源加载和构建过程,get到了没。

6. 模块路径重写

Vite 在处理模块路径时,需要将基于 Node.js
require.resolve
的路径转换为浏览器可识别的路径。此外,Vite 还支持使用
@
符号作为
src
目录的别名,这在 Vue 单文件组件(SFC)中尤为常见。来看一下这个案例和解析:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- public/
|-- src/
|   |-- components/
|   |   |-- MyComponent.vue
|   |-- views/
|   |   |-- Home.vue
|   |-- main.js
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import Home from '@/views/Home.vue';

   createApp(Home).mount('#app');
  1. Home.vue
    (
    src/views/Home.vue
    ):
   <template>
     <div>
       <h1>Welcome to the Home page</h1>
       <my-component />
     </div>
   </template>

   <script>
   import MyComponent from '@/components/MyComponent.vue';

   export default {
     components: {
       MyComponent
     }
   }
   </script>
  1. MyComponent.vue
    (
    src/components/MyComponent.vue
    ):
   <template>
     <div>I'm a component!</div>
   </template>

   <script>
   export default {
     name: 'MyComponent'
   }
   </script>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     // 设置别名 @ 指向 src 目录
     alias: {
       '@/': '/src/',
       'vue': 'vue/dist/vue.esm.js'
     }
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0"
     }
   }

启动开发服务器

在项目根目录下,运行以下命令来启动开发服务器:

npm run dev

解析

  1. 别名配置



    • vite.config.js
      中,我们设置了
      @
      别名指向项目的
      src
      目录。这样,我们就可以使用
      @/views/Home.vue
      这样的路径来代替相对路径或绝对路径。
  2. 模块请求



    • Home.vue

      main.js
      引用时,浏览器会发送一个请求到 Vite 开发服务器,请求
      @/views/Home.vue
  3. 路径重写


    • Vite 服务器接收到请求后,会根据配置的别名将
      @/views/Home.vue
      转换为实际的文件路径
      /src/views/Home.vue
  4. 处理单文件组件


    • Vite 使用
      es-module-lexer
      来解析
      import
      语句,并根据浏览器对 ES 模块的支持来重写模块路径。
  5. 服务模块


    • Vite 将请求的模块进行处理,如果是 Vue 单文件组件,Vite 会将其分解为
      .js

      .css
      (如果有)等不同的模块,并单独服务这些模块。
  6. 浏览器加载


    • 浏览器根据 Vite 服务的路径加载模块,由于路径已经被转换为浏览器可识别的路径,模块能够正确加载并执行。

Vite 通过模块路径重写支持别名和单文件组件的按需加载,从而提高开发效率和模块化管理的便利性,有点意思。

7. 构建优化

构建优化是 Vite 在生产环境下的关键特性,它确保最终的静态资源被压缩、分割和优化以提高应用的性能。以下是构建优化的案例和解析:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- public/
|-- src/
|   |-- main.js
|   |-- App.vue
|   |-- SomeLib.js
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';
   import SomeLib from './SomeLib';

   createApp(App).mount('#app');

   // 使用库函数
   SomeLib.doSomething();
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div id="app">
       <h1>My Vue App</h1>
     </div>
   </template>

   <script>
   export default {
     name: 'App'
   }
   </script>
  1. SomeLib.js
    (
    src/SomeLib.js
    ):
   export function doSomething() {
     console.log('Library function called');
   }
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   module.exports = {
     build: {
       // 配置生产环境构建选项
       minify: 'terser', // 使用 terser 进行代码压缩
       sourcemap: true,   // 生成 sourcemap 文件
       rollupOptions: {
         output: {
           manualChunks: {
             lib: ['src/SomeLib.js'], // 将 SomeLib.js 单独打包成一个 chunk
           }
         }
       }
     }
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "build": "vite build"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "vite": "^2.0.0",
       "terser": "^5.0.0"
     }
   }

构建项目

在项目根目录下,运行以下命令来构建项目:

npm run build

解析一下

  1. 配置构建选项



    • vite.config.js
      中,我们配置了构建选项,包括代码压缩工具
      terser
      和 sourcemap 生成。
  2. 手动分块


    • 我们使用
      rollupOptions
      中的
      manualChunks
      配置将
      SomeLib.js
      单独打包成一个 chunk,这有助于按需加载和缓存。
  3. 执行构建


    • 运行
      npm run build
      后,Vite 会启动构建流程,根据配置进行代码压缩、分块等操作。
  4. 代码压缩


    • 使用
      terser
      ,Vite 会压缩 JavaScript 代码,移除多余的空格、注释,并进行一些优化以减少文件大小。
  5. 生成 sourcemap


    • 构建过程中,Vite 会生成 sourcemap 文件,这有助于在生产环境中调试代码。
  6. 输出构建结果


    • 构建完成后,Vite 会在
      dist
      目录下生成优化后的静态资源,包括 JavaScript、CSS 和其他静态资源文件。
  7. 部署


    • 构建结果可以直接部署到生产服务器,由于进行了优化,应用的加载速度和性能会得到提升。

Vite在生产环境中进行构建优化,包括代码压缩、手动分块、sourcemap 生成等,以确保应用的性能和可维护性。

8. 插件系统

Vite 的插件系统允许开发者扩展 Vite 的功能,例如添加对特定类型文件的支持、优化构建流程等,来看一下:

项目结构

假设我们有以下项目结构:

/my-vue-app
|-- node_modules/
|-- src/
|   |-- main.js
|   |-- App.vue
|   |-- assets/
|       |-- image.png
|-- vite.config.js
|-- package.json

文件内容

  1. main.js
    (
    src/main.js
    ):
   import { createApp } from 'vue';
   import App from './App.vue';
   import './assets/image.png';

   createApp(App).mount('#app');
  1. App.vue
    (
    src/App.vue
    ):
   <template>
     <div>
       <img src="image.png" alt="Image">
     </div>
   </template>

   <script>
   export default {
     name: 'App'
   }
   </script>
  1. vite.config.js
    (
    vite.config.js
    ):
   // vite.config.js
   import vue from '@vitejs/plugin-vue';
   import path from 'path';
   import imageTransformPlugin from './imageTransformPlugin'; // 假设我们创建了一个自定义插件

   module.exports = {
     plugins: [
       vue(), // Vite 官方 Vue 插件,用于处理 Vue 单文件组件
       imageTransformPlugin, // 使用自定义插件来处理图像转换
     ],
     // 其他配置...
   };
  1. imageTransformPlugin.js
    (
    imageTransformPlugin.js
    ):
   // 自定义插件来处理图像文件
   export default {
     name: 'image-transform-plugin',

     transform(code, id) {
       if (id.endsWith('.png')) {
         // 假设我们对图像进行某种转换处理
         const transformedCode = code.replace(/.png$/, '-transformed.png');
         return {
           code: transformedCode,
           map: null, // 这里简化处理,实际插件应该生成 sourcemap
         };
       }
     },
   };
  1. package.json
    (
    package.json
    ):
   {
     "scripts": {
       "dev": "vite",
       "build": "vite build"
     },
     "dependencies": {
       "vue": "^3.0.0"
     },
     "devDependencies": {
       "@vitejs/plugin-vue": "^2.0.0",
       "vite": "^2.0.0"
     }
   }

使用插件

  1. 安装插件


    • 首先,我们安装了
      @vitejs/plugin-vue
      ,这是 Vite 官方提供的插件,用于支持 Vue 单文件组件。
  2. 创建自定义插件


    • 我们创建了一个名为
      imageTransformPlugin
      的自定义插件,用于处理图像文件的转换。
  3. 配置插件



    • vite.config.js
      中,我们将这两个插件添加到
      plugins
      数组中,这样 Vite 在构建时就会应用这些插件。
  4. 运行 Vite


    • 当你运行
      npm run dev

      npm run build
      时,Vite 会使用配置的插件来处理项目资源。

过程解析

  • Vue 插件
    (
    @vitejs/plugin-vue
    ):


    • 这个插件使得 Vite 能够识别和处理
      .vue
      文件,将它们分解为 JavaScript、CSS 和模板代码,并应用 HMR。
  • 自定义图像转换插件
    (
    imageTransformPlugin
    ):


    • 我们的自定义插件拦截了以
      .png
      结尾的文件请求,并对其进行了简单的“转换”(这里只是示例,实际中可能是更复杂的图像处理逻辑)。
  • 插件钩子


    • Vite 插件系统提供了多种钩子(例如
      transform
      ),允许插件在构建过程中的不同阶段介入和修改处理逻辑。
  • 应用插件


    • 当请求图像资源时,Vite 会首先查找是否有插件处理该资源。在我们的案例中,自定义插件会拦截请求并返回“转换”后的资源。

Vite 插件系统允许开发者自定义构建流程,增强 Vite 的功能,以适应各种开发需求。

最后

除了以上这些,Vite 还对 Vue 3 和 React 17+ 的官方支持,允许开发者使用这些前端框架进行开发。Vite 内置了对 TypeScript 的支持,无需额外配置即可使用 TypeScript 开发。Vite能够处理 CSS 文件和各种静态资源,包括图片、字体等,并支持 CSS 预处理器。Vite 能够生成 Sourcemap,方便开发者在浏览器中调试源代码。Vite 支持在构建过程中注入环境变量,使得配置更加灵活。如果你还知道哪些关于 Vite 的能力,欢迎给 V 哥点拨一下,在此感谢。

这些核心组件功能共同构成了 Vite 的强大能力,使它成为一个高效、灵活且易于使用的前端构建工具,如果你还没用上 Vite,那就抓紧搞起来吧。

在设计领域,Figma 无疑是一个巨人。它彻底改变了设计流程,将协作带到了一个全新的高度。然而,随着 Adobe 收购 Figma 的消息传出,许多设计师和开发者开始担心:Figma 未来会如何演变?那些好用的特性会不会被砍掉?

出于白嫖的本能,大家都想寻找一个强大而可靠的 Figma 替代品。在众多候选者中,有一个名字正在迅速崛起,那就是 Penpot。

Penpot 不仅仅是一个设计工具,它还代表了一种全新的设计理念。作为第一个真正开源的设计和原型工具,Penpot 正在重新定义设计师和开发人员之间的协作方式。它不仅继承了 Figma 的许多优秀特性,还在某些方面超越了 Figma。

本文我们将详细介绍 Penpot 的核心特性、技术架构,以及安装和使用方法。

Penpot 介绍

Penpot
是第一个面向设计和代码协作的开源设计工具。它由西班牙公司 Kaleidos 开发,于 2015 年正式发布。作为一个基于浏览器的设计工具,Penpot 支持矢量图形编辑、原型设计、组件库构建等核心功能,同时还提供了独特的代码协作能力。

Penpot 的核心理念是 “Design with Code in Mind” (以代码为中心的设计)。它使用开放标准 (如 SVG、CSS 和 HTML) 作为底层技术,确保设计输出可以直接被开发者使用。这种方式大大缩短了设计到开发的转换时间,提高了团队整体效率。

截至目前,Penpot 在 GitHub 上已获得接近 32000 颗星,拥有超过 160 名贡献者。

最新发布的 Penpot 2.0 版本带来了一系列重大改进,进一步提升了设计和开发的协作体验:

  1. CSS Grid 布局:引入了强大的 CSS Grid 布局功能,使设计师能够创建更灵活、响应式的布局,同时生成符合现代 Web 标准的代码。
  2. 全新 UI 设计:经过重新设计的用户界面不仅提升了美观度,更重要的是优化了工作流程,提高了操作效率。
  3. 改进的组件系统:新的组件系统使创建、管理和重用设计元素变得更加简单,有助于保持设计的一致性和可维护性。
  4. 性能优化:整体性能得到显著提升,特别是在处理大型复杂项目时,响应更快,操作更流畅。
  5. 增强的可访问性:新版本在可访问性方面做了很多改进,使得更多用户能够方便地使用 Penpot。

Penpot 的技术架构

要充分理解 Penpot 的强大,我们需要深入了解其技术架构。

Penpot 采用典型的 SPA 架构。前端使用 ClojureScript 和 React 框架编写,由静态网络服务器提供服务。它与后端应用程序对话,后端应用程序将数据持久保存在 PostgreSQL 数据库中。

后端使用 Clojure 编写,因此前后端可以很轻松地共享代码和数据结构。最后将代码编译成 JVM 字节码,并在 JVM 环境中运行。

整体架构如下:

下面我们分别来看看各个组件的架构。

后端架构

Penpot 的后端主要使用 Clojure 编写,这是一种运行在 JVM 上的函数式编程语言。后端负责数据的 CRUD 操作、完整性验证以及数据持久化。

主要组件包括:

  • HTTP 服务器
    :处理 API 请求和路由。
  • RPC 系统
    :允许前端安全地调用后端函数。
  • 数据库
    :使用 PostgreSQL 存储核心数据。
  • 文件存储
    :用于存储媒体附件。
  • 异步任务系统
    :处理耗时操作,如发送邮件和遥测数据收集。
  • WebSocket
    :实现实时协作和通知。

前端架构

Penpot 的前端使用 ClojureScript 编写,这是 Clojure 语言编译到 JavaScript 的版本。它采用了 React 框架,通过 rumext 库进行封装。

主要模块包括:

  • 全局状态管理
    :使用类似 Redux 的事件循环范式。
  • UI 组件
    :包括仪表板、工作区、查看器等核心功能模块。
  • 渲染引擎
    :负责将设计转换为 SVG 元素。
  • Web Worker
    :处理耗时操作,如生成缩略图和维护几何索引。

导出器

Penpot 还包含一个专门的导出器应用,使用无头浏览器 (headless browser) 确保导出结果与屏幕上看到的完全一致。这个组件可以生成位图、PDF 或 SVG 格式的导出文件。

Penpot 的核心功能

了解了 Penpot 的技术架构,我们再来看看它能为设计师和开发人员带来哪些实际的价值:

为设计师打造

  1. 直观的设计界面
    :Penpot 提供了一个熟悉而强大的设计环境,设计师可以轻松创建复杂的设计。
  2. 响应式设计
    :借助 CSS Grid 布局,设计师可以轻松创建适应各种屏幕尺寸的设计。
  3. 组件和设计系统
    :Penpot 的组件系统允许创建可重用的设计元素,极大提高了效率和一致性。
  4. 交互原型
    :设计师可以添加交互和动画,创建高保真原型。
  5. 实时协作
    :多个设计师可以同时在一个文件上工作,提高团队效率。

为开发人员设计

  1. 检查模式
    :开发人员可以轻松获取设计的 CSS、HTML 和 SVG 代码。
  2. 精确规格
    :Penpot 提供准确的尺寸、颜色和其他设计规格,减少猜测工作。
  3. 设计令牌
    :开发人员可以直接使用设计中定义的颜色、字体和其他样式变量。
  4. 版本控制
    :Penpot 支持版本历史,便于跟踪设计变更。
  5. API 和 Webhooks
    :允许将 Penpot 集成到现有的开发工作流中。

团队协作

  1. 实时评论
    :团队成员可以直接在设计上添加评论,促进有效沟通。
  2. 权限管理
    :灵活的权限系统确保正确的人员访问正确的内容。
  3. 共享库
    :团队可以创建和共享设计资源,保持一致性。
  4. 导出选项
    :支持多种格式的导出,满足不同场景需求。

安装 Penpot

Penpot 有多种安装方式,有技术能力的同学可以通过 Docker 镜像来部署。需要部署的组件比较多,除了前端、后端和导出器之外,还需要部署两个数据库 PostgreSQL 和 Redis,如果你还想要实现高可用,那么对象存储也是必不可少的。

对于没有技术背景的同学而言,你也不用担心安装问题,
Sealos 应用商店
提供了一键部署的应用模板,点一下鼠标即可完成部署,非常丝滑。
而且不需要再单独购买具有公网 IP 的服务器了,直接按量付费即可。

如果你想快速部署一个 Penpot,又不想陷入繁琐的安装和配置过程
,可以试试 Sealos。

直接打开
Penpot 应用模板
,然后点击右上角的 “去 Sealos 部署”。

如果您是第一次使用
Sealos
,则需要注册登录 Sealos 公有云账号,登录之后会立即跳转到模板的部署页面。

什么都不用填,直接点击右上角的 “部署应用” 开始部署。部署完成后,直接点击前端应用的 “详情” 进入前端应用的详情页面。

等待应用状态变成 running 之后,直接点击外网地址便可打开 Penpot 的 Web 界面。

首先点击 “创建账户” 注册一个账号:

填一下问卷,总共有五步:

填完之后就可以开始正式使用了,你可以选择创建团队,也可以自己独立使用。

除此之外,还有另外一种打开方式,先刷新 Sealos 桌面 (也就是在
cloud.sealos.run
界面刷新浏览器),然后你就会发现 Sealos 桌面多了个图标:

直接点击这个图标就可以打开 Penpot 的 Web 界面。

是不是有点似曾相识?没错,很像
Windows 的快捷方式!

单机操作系统可以这么玩,Sealos 云操作系统当然也可以这么玩。

Penpot 的基本使用

要正式开始使用 Penpot,首先需要了解一些基本概念和操作。

画板

画板是 Penpot 中最基本的容器对象,通常用于创建特定尺寸的设计。你可以根据你的需要,选择一个特定的屏幕或打印用的尺寸。

  • 创建画板:使用工具栏中的画板工具,或按快捷键 B。
  • 选择画板:点击画板名称或 Ctrl/⌘ + 点击画板区域。
  • 设置缩略图:选中画板,右键选择 “Set as thumbnail” 或按 Shift + T。
  • 剪裁内容:画板可以选择是否剪裁其内容。
  • 在查看模式显示:控制画板是否作为单独屏幕在查看模式中显示。

色盘

Penpot 提供了强大的颜色管理工具,包括颜色选择器和颜色面板。

颜色选择器

  1. 吸管工具
    :从视窗中的任何对象拾取颜色。
  2. 颜色配置
    :在 RGB、色轮或 HSV 之间切换。
  3. 颜色类型
    :纯色、线性渐变、径向渐变或图像。
  4. 滑块
    :调整亮度、饱和度或不透明度。
  5. 数值
    :精确设置红 (R)、绿 (G)、蓝 (B) 和透明度 (A) 值。

  6. :在最近使用的颜色和库之间切换。

颜色面板

  • 可以通过主菜单、工具栏按钮或颜色选择器中的启动器显示/隐藏。
  • 使用菜单在库之间切换。
  • 可以切换大小缩略图大小。

组件

组件是可以在多个文件中重复使用的对象或对象组,有助于保持设计的一致性。

创建组件

  1. 选择一个对象或一组对象。
  2. 按 Ctrl + K 或右键选择 “Create component”。

组件操作

  • 复制组件
    :创建链接到主组件的组件副本。
  • 复制为主组件
    :从资产侧边栏复制为新的主组件。
  • 删除主组件
    :谨慎操作,会同时删除资产库中的组件。
  • 恢复主组件
    :可以从组件副本恢复已删除的主组件。
  • 组件分组
    :使用斜杠 (/) 命名或使用 “Group” 选项创建组件组。
  • 查找组件
    :可以在资产面板和设计视口中查找组件。

组件覆盖

  • 允许在保持与主组件同步的同时修改特定副本。
  • 可以重置覆盖以恢复到主组件状态。

组件交换

允许轻松替换组件副本:

  1. 选择一个组件副本。
  2. 在右侧边栏中点击组件名称启动交换菜单。
  3. 选择要交换的组件并点击。

原型设计

Penpot 允许通过连接画板来创建交互原型,模拟用户如何在屏幕间导航。

连接画板

  1. 打开至少包含两个画板的文件。
  2. 在右侧边栏激活 “Prototype mode”。
  3. 选择触发交互的图层。
  4. 从选中图层拖动连接到目标画板。
  5. 自动创建流程起点。
  6. 在查看模式中启动交互原型。

交互触发器

定义启动交互的用户动作,包括:

  • 点击 (On click)
  • 鼠标进入 (Mouse enter)
  • 鼠标离开 (Mouse leave)
  • 延迟后 (After delay)

交互动作

定义触发交互后发生的事件,包括:

  • 导航到 (Navigate to)
  • 打开叠加层 (Open overlay)
  • 切换叠加层 (Toggle overlay)
  • 关闭叠加层 (Close overlay)
  • 上一屏幕 (Previous screen)
  • 打开 URL(Open URL)

交互动画

定义触发交互时画板之间的过渡效果,包括:

  • 溶解 (Dissolve)
  • 滑动 (Slide)
  • 推送 (Push)

流 (Flows)

允许在同一页面内定义多个起点,以更好地组织和展示原型:

  • 自动创建起点
  • 从原型侧边栏添加起点
  • 从画板菜单添加起点
  • 在查看模式中切换不同流程

固定元素

可以固定对象在滚动时的位置,适用于原型设计中的固定头部、导航栏和浮动按钮等元素。

Figma 导出

Penpot 还提供了一个
Figma 插件
,用于将 Figma 设计稿导出,可支持基本形状、面板、组、填充的导出,文本、图像具有基本功能支持。但目前自动布局、组件等关键的的功能都未能得到支持。

总结

虽然目前商业设计软件如 Figma 在市场上占据主导地位,但 Penpot 这样的开源工具正在快速崛起,并可能在未来超越商业软件。开源模式允许更多开发者参与,能够带来更快的创新速度和更强的功能适应性。同时,开源工具的透明性和可定制性,可能更好地满足不同团队的需求,最终成为行业新标准。