2024年4月

前面我们已经聊过众多指令微调的方案,这一章我们重点讨论下如何注入某一类任务或能力的同时,尽可能不损失模型原有的通用指令理解能力。因为在下游或垂直领域应用中,我们设计的推理任务风格或形式,往往很难通过prompt来稳定实现。这种情况下就会依赖微调来注入稳定的推理风格,但同时在部分场景下我们又需要模型微调前的通用指令理解能力。虽然理论上说有得必有失,但成年人真的不想做选择!这里我们讨论两种方案,来尽可能降低通用能力的损失,一种数据方案,一种训练方案。

Two Stage Finetune - ProMoT

  • Preserving In-Context Learning ability in Large Language Model Fine-tuning
  • TWO-STAGE LLM FINE-TUNING WITH LESS SPECIALIZATION AND MORE GENERALIZATION

先说训练方案,论文先是分析了模型在微调过程中能力损失的一个主要原因是
Format Specialization
,也就是模型过拟合了微调任务的输出格式。举几个例子

  • 下游是二分类任务的,微调之后即便丢掉分类任务的指令,模型的输出还是True/False,损失了对话和其他指令理解能力
  • 下游是摘要任务的,微调之后即便丢掉TL;DL的总结指令,你让模型翻译模型还是给你总结。这个在当前推出的一些长文本无敌的基座模型上可能会看到,他们后期的指令微调中指令分布都比较偏向于长文本的QA和总结,其他能力会略弱
  • 通过微调让模型学习拒绝回答的,你会发现你的模型可能在很多不该拒绝的场景也拒绝你

其实核心就是我们本想让模型学习条件生成能力,也就是在分类指令时分类,摘要指令下摘要,该拒绝的场景下再拒绝。但论文通过分析发现在传统微调任务中,模型是先无脑拟合输出格式,例如二分类的True/False,拒绝里的对不起,在微调后期才开始学习input和ouput之间的关系(Semantic Relation),例如何时该分类,何时该拒绝。

那能否把下游任务的Task Format先行注入到额外的参数中,之后把这部分信息喂给模型,让大模型微调直接学习Semantic Relation,这样在稳定注入新的Format的前提下,尽可能不影响其他input的推理格式。

论文提出了两阶段微调,第一阶段也使用了谷歌提出的Prompt Tuning用来学习Format,第二阶段再进行全量微调。如下
image

第一阶段Prompt Tuning
,简单说就是冻结大模型,只微调Embedding层的一组虚拟Token,这一组虚拟Token学习的就是下游推理任务Format的任务表征。

这里可以反过来思考,之前有论文提出任务指令(prompt)其实可以压缩成一个Task Vector的任务表征用来引导模型给出不同的推理输出;那反过来我们想让模型学习一种推理风格/任务,其实就是构建该Format对应的Task Vector,以及Task Vector对应的任务指令的过程。只不过prompt tunning的prompt使用的是虚拟Token。想更多了解Prompt Tuning的童鞋看这里
解密Prompt系列3. 冻结LM微调Prompt: Prefix-tuning & Prompt-tuning & P-tuning

image

第二阶段Fine-tuning
默认在输入层Embedding前拼接Prompt Embedding,b并冻结这部分Embedding,然后全量微调大模型, 让模型在已知输出格式的前提下,学习Input和Output格式之间的Semantic联系。之前有些疑惑这里为何要冻结prompt,后来又想了想应该是为了避免模型再把Task Format相关信息更新到模型内部参数中,但感觉不冻结的方式也值得测试下。

几个值得聊聊的细节

  1. 第一阶段微调能否用Lora,从Prompt实际学习的是推理格式的任务表征这个逻辑原理来说其实Adapter类的微调方案,似乎并不合理。论文测试后也发现Lora的效果并不好
  2. 能否把两个阶段合二为一,既加上一个虚拟Prompt,同时微调模型和prompt,论文测试后发现效果和SFT相差不多,都会有过拟合。毕竟这种微调方式无法引导模型把格式学到Prompt Embedding上。

效果上,论文在mT5模型上对比了SFT,Prompt-Tuning,和ProMoT在下游微调任务,和微调任务之外其他通用任务的能力对比。发现ProMoTe可以在分类,翻译,NLI,QA等任务微调上对比全量微调都有更好的效果。同时以分类任务为例,在分类任务上进行微调后,在QA等其他任务上相比基座模型能力也没有显著下降,作为对照组的SFT会有显著的通用能力的下降。

image

image

Dual-Stage Mixed Finetuning - DMT

  • How Abilities in Large Language Models are Affected by Supervised Fine-tuning Data Composition
  • Scaling Relationship on Learning Mathematical Reasoning with Large Language Models

DMT的论文主要探究了不同领域数据混合,以及混合训练策略对模型在多领域能力的影响。

1. 单领域Scaling curve

要想设计更合理的多领域混合训练策略,首先要确认不同领域样本模型学习的scaling curve。这个问题之前已经有很多论文讨论过,这里简单回顾下,如下图所示

image

  • 数学和代码等领域能力,会随样本量上升而持续提升,并且模型规模越大scaling curve越单调且陡峭。
    这一点和我们的测试效果相似,数学和代码样本你就可劲加,加一点模型好一点,更多细节看上面Scaling的论文。
  • 通用指令能力,基本在1K(1/256的样本)的样本上效果就很好了,后续能力提升会比较慢,并且在不同规模的模型上差异相对有限。
    这一点我们在前文讨论过详见
    LLM对齐经验之数据越少越好?

2. 多领域混合Scaling curve

明确单一领域的scling curve之后,我们来看多领域的数据混合,这里会分别讨论数据混合中的两个要点:整体量级和混合比例

  1. 整体量级:和以上单领域实验相同的5种不同采样比例,直接对三个领域的数据进行混合,和上面的单领域实验结果进行对比。观察下图会发现
    在低资源上领域混合会有提升,但在更大的样本量级上单领域微调效果会略好
    一个可能的解释是在小量级样本上会有彼此的能力迁移,而当单领域信息逐步提升后信息冲突会逐渐显现

image

  1. 混合比例:为了进一步探究以上全样本混合训练中出现的信息冲突的来源,作者进一步做了控制变量的实验。固定一个领域(math和code合成一个领域)的样本量改变另一个领域的样本量,看不同比例数据混合的影响。主要结论有
  • 主领域样本还是越多越好
  • 当领域样本差异(输出格式/输入分布)较大时,通用领域数据对特殊领域影响有限
  • 当样本存在相似性时混合会带来冲突,但冲突和数据比例没有显著单调性

image

3. 训练策略影响

论文实验了不同训练策略的影响,包括多领域联合训练,有序训练(Code->Math->General),以及先训练Math+Code再训练general的有序混合训练,。这几种策略之前也有很多论文做过测试,这里简单说下结论

  • 多领域联合训练:会更多学到特殊领域(Math+code),更多损伤通用能力。这块可以更多借用ProMoT的逻辑,因为特殊领域输出风格一致模型更容易学到,而通用领域输出风格更多样些
  • 有序训练和有序混合训练:只要是先训练领域能力再训练通用能力,因为灾难遗忘的原因,最终模型会把先学到的领域能力遗忘

image

在以上三种训练方案的基础上,论文提出了两阶段混合训练(DMT)如下

第一阶段是领域数据的训练
,按照单领域scaling curve,这一部分的数据量越大效果越好,所以使用全量级的数学和代码进行训练。

第二阶段用于恢复通用能力,同时尽量避免有序训练带来的灾难遗忘。
这里使用了上面多领域混合的insight,领域数据的混合比例对通用能力影响较小;同时低资源混合带来的冲突较小。因为论文使用了
1/256的领域数据和通用数据进行混合进行第二阶段的训练
。在尽量避免第一阶段模型学到的能力丢失的基础上,帮助模型恢复通用能力。

效果上在LLaMA7B,13B,和33B的模型上,
DMT的训练方案能在保留单领域训练绝大多数领域能力的基础上,保证模型通用能力不受损失,甚至略微有所提升
。如果想要保留更多的领域能力,允许更多的通用能力损失,则可以适当提高第二阶段的领域数据占比,具体要数据集上case by case的测试。
image

想看更全的大模型相关论文梳理·微调及预训练数据和框架·AIGC应用,移步Github >>
DecryPrompt


《深入解析C++的auto自动类型推导》

《深入解析decltype和decltype(auto)》
两篇文章中介绍了使用auto和decltype以及decltype和auto结合来自动推导类型的推导规则和用法,虽然确定类型的事情交给编译器去做了,但是在有的时候我们可能还是想知道编译器推导出来的类型具体是什么,下面就来介绍几种获取类型推导结果的方法,根据开发的不同阶段,你可以在不同阶段采用不同的方法,比如在编写代码时,编译代码时,代码运行时。

利用IDE查看

当你在编写代码的过程中想查看一下某个变量推导出来的类型是什么,做到心中有数,其实在IDE中就可以直接查看,现在的IDE都比较智能,如微软的Visual Studio和目前比较流行的跨平台编辑器VS Code都有此功能。你只要将鼠标移到想要查看的那个变量上面,就会弹出这个变量的类型,不过要让IDE能够推导出代码中变量的类型,你的代码至少要没有语法错误,因为IDE会静态分析你代码来推导出这些类型,如下面的代码:

int a;
auto x = a;
auto& y = a;

你把鼠标移动x上面,则会弹出显示“int x”,把鼠标移动y上面,就会弹出显示“int& y”。对于C++的内置类型,IDE基本上都能推导出来,但是遇到比较复杂的类型或者复杂的代码上下文中,IDE可能就有点不够智能了。

借助工具查看

当IDE不能正确显示出变量的类型的时候还可以选择借助外部的工具来查看,这里推荐一个在线工具,地址是:
https://cppinsights.io
,这是一个基于Clang的工具,对用户所写的C++代码转换成最终形式的C++代码,有点类似于C/C++的预处理器一样,把一些宏代码替换成真实的代码一样,但它的功能更进一步也更强大,该工具支持基于范围的循环、结构化绑定、生成默认构造函数、初始化列表、auto与decltype转换成真实类型,最强大的是会生成模板实例化后的代码,这些功能对于调试C++代码非常有用。使用的界面如下:

(点击查看大图)

左边是我们输入的原始代码,输入结束之后点击左上角的三角形按钮,就会生成右边经过转换后的代码,可以看到右边中已经将类型别名T1到T10等的类型转换成具体的类型了,使用时可以在上面的下拉列表框中选择不同的C++标准。

需要注意的是,这个工具我发现了一个Bug,就是上面代码中的T9类型别名,正确的类型应该是func函数的类型:int(int, int),这里显示为它的返回值的类型了。

编译时打印

编译器肯定是知道变量的类型的,但是它没法直接告诉你,有一个可以让编译器告诉你的办法,就是编译发生错误时编译器在报告的错误信息中肯定会提到导致此错误的类型,因此我们可以声明一个如下的模板:

template<typename T>
class dumpType;

因为上面的模板只有声明,没有具体的定义,因此如果要实例化这个模板就会导致一个编译错误。所以我们想要查看哪个变量的类型,只要将这个变量的类型作为模板的形参去实例化它,就会导致一个错误,在编译器给出的错误信息里就会显示出这个变量的具体类型,如下所示:

const int x1 = 1;
auto j = x1;
dumpType<decltype(j)>{};

编译时发生错误,其中输出的错误信息含有这一行:

error: implicit instantiation of undefined template 'dumpType<int>'

dumpType
中尖括号内的int就是j的类型了,以此类推,只要将想要查看的变量替换到上面的参数中就可以了。但是这里有一个缺点,就是每次只能查看一个变量的类型,需要查看多个变量时就显得繁琐。好在C++11标准引入了支持可变参数的模板特性,我们可以利用这个特性来完善上面的功能,将上面的模板修改一下:

template<typename... Ts>
class dumpType;

现在可以一次传递多个参数给此模板,如下面的例子:

template<typename... Ts>
class dumpType;

int func(int, int) {
    int x;
    return x;
}

class Base {
public:
    int x = 0;
};

int main() {
    const Base b;
    const int ci = 1;
    auto x = ci;
    using T1 = decltype(x);
    using T2 = decltype((x));
    using T3 = decltype(b.x);
    using T4 = decltype((b.x));
    using T5 = decltype(func);
    dumpType<T1, T2, T3, T4, T5>{};
}

编译时将输出以下的错误信息:

error: implicit instantiation of undefined template 
'dumpType<int, int &, int, const int &, int (int, int)>'

运行时输出

有时我们想要在代码运行的时候输出某些变量的类型,这时候可以借助C++的RTTI特性,C++标准库提供了typeid函数和type_info类,对变量或者类型调用typeid会返回一个type_info对象,type_info类里有一个成员函数name,这个函数返回一个const char*类型的名称,但这个名称一般都经过C++的混淆,比较不易看懂,如以下的代码:

auto add (auto p1, auto p2) { return p1 + p2; };
auto d = add(1, 2.0);
printf("type of d is %s\n", typeid(d).name());
auto s = add("hello"s, "world"s);
printf("type of s is %s\n", typeid(s).name());

输出的结果是:

type of d is d
type of s is NSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE

输出结果中的d代表的是double类型,如int类型的话则显示i,std::string类型的原型比较复杂,所以输出来的结果比较难看懂。但这种方法最大的缺点是功能不太完善,比如对于引用类型它无法正确的显示出来,比如下面的代码:

int i = 1;
auto& j = i;
printf("type of j is %s\n", typeid(j).name());

变量j正确的类型应该是int&,但是上面的输出结果是i,是int类型,估计是j作为参数传给typeid函数的时候是作为值传递的,丢失了引用属性,在这里CV修饰词也会被忽略掉,如在上面定义变量j时加上const修饰,但输出结果还是int类型。

这时可以采用另外一种手段来输出变量的类型,跟上小节中的例子一样借助模板的技术,实现一个模板函数,在模板函数中利用编译器提供的宏,把这个函数的原型打印出来,函数原型中就包含了函数的参数个数及其类型,这个宏由于不是C++标准中定义的,是由各编译器扩展的,因此名称不一样,在GCC/Clang中是__PRETTY_FUNCTION__,在微软的MSVC中是__FUNCSIG__,如下代码:

#include <iostream>

template<typename... Ts>
void dumpType() {
    // GCC/Clang使用这行
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    // MSVC则使用下面这行
    //std::cout << __FUNCSIG__ << std::endl;
};

int func(int, int) {
    int x;
    return x;
}

class Base {
public:
    int x = 0;
};

int main() {
    const Base b;
    const int ci = 1;
    auto x = ci;
    using T1 = decltype(x);
    using T2 = decltype((x));
    using T3 = decltype(b.x);
    using T4 = decltype((b.x));
    using T5 = decltype(func);
    dumpType<T1, T2, T3, T4, T5>();
}

各个编译器输出的结果是:

// Clang
void dumpType() [Ts = <int, int &, int, const int &, int (int, int)>]
// GCC
void dumpType() [with Ts = {int, int&, int, const int&, int(int, int)}]
// MSVC
void __cdecl dumpType<int,int&,int,const int&,int(int,int)>(void)


此篇文章同步发布于我的微信公众号:
查看自动类型推导结果的方法

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,或者扫描以下二维码关注,以便在内容更新时直接向您推送。
image

前言

大家好,这里是白泽。
《Go语言的100个错误以及如何避免》
是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“
Go: Simple to learn but hard to master
”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。

我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第八篇文章,对应书中第61-68个错误场景。


自然流量的惊喜

书接上文,凭着短视频的好奇,搭了个小程序,做了文案提取,配音等功能,也顺带写了两篇口水文章,不曾想居然收获历史最高的点赞与收藏。有兴趣的朋友可以点这里一看究尽:《
短视频配音原来如此简单
》,《
短视频文案提取的简单实现
》。做为一个食人间烟火的程序员,也偷偷的去看了数据,由于没抱太大的期望,自然流量给了我一个大大的惊喜,下图是没有任何推广的数据。我一度暗暗自喜,直到我上线了另一个小程序做对照组时,也就是上两个文章中提到的小程序,几乎没有自然流量,即使这个小程序功能更全,体验更好,所有用户都是从我的文章中关联而来的,没有自然流量。我瞬间明白了:论小程序取名的重要性。

告警呼啸而至

小程序上线后,总于可以睡上安稳觉了。于是又开始早上6:30去学校带小朋友跑步了。跑了一年了,好几个小朋友算是跑上道了。跑得正酣畅淋漓之时,突然,企微告警群开始咚咚告警:resource pack exhausted! Please purchase resource packs... 30小时的资源包才买几天怎么就耗尽了呢。跑完步,在学校噌了早饭,小电驴儿一溜烟回家打开电脑,巴拉出访问日志,傻眼了。这样一个没名没份的小程序,居然有人在刷它的接口(大部分都是视频文案提取,原来还有这么多人在做短视频),心中顿感五味杂成,有人刷说明功能还不多,这样刷地主家也没有余粮了...

签名保驾护航

既然来了,只能接招了。既然刷接口,那就对接口访问做一些校验。目前小程序只是提文案提取等功能,所以首先想到接口做个签名,防止别人使用程序自动刷。考虑小程序源码获取比较困难,签名字段根据sha1简单生成就可以了,未来如果这个也行不通,再使用RSA加密下sign字段就可以了。sign生成规则比较简单,timestamp,request,随机串,请求参数,排序 sha1就可以了。代码如下。

前端只需要在request中 生成签名,放到header里就行了。

functionsign(json) {
json.timestamp
=getTimestamp();
json.rand
= mtRand(100000, 999999);
json.appkey
=app.globalData.secretKey;

let valueArray
=[];for (let key injson) {
valueArray.push(json[key]);
}
valueArray.sort();

let signStr
=jsonVAL(valueArray);
console.log(
"signStr", signStr);

json.sign
=sha1Util.sha1(signStr);deletejson.appkey;returnjson;
}

后端也简单,根据一样的规则,一样的key,生成sign,对比前端的sign字段就可以了。自定义HandlerInterceptor,并注册到InterceptorRegistry中就。

代码如下;

/*** sign校验拦截器
*
@authorJJ*/@Slf4j
@Component
public class CheckSignInterceptor implementsHandlerInterceptor {private static final String SecretKey = "*******";//签名过期时间(s) private static final Integer TimestampOut = 300;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throwsIOException {


RequestWrapper requestWrapper
= newRequestWrapper(request);
String body
=requestWrapper.getBody();
Result result
= this.check(body);if (!result.getSuccess()) {
log.info(
"签名失败:{}", body);//设置状态码为401,表示未授权 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);//设置响应内容类型和字符集 response.setContentType("application/json;charset=UTF-8");//自定义输出 response.getWriter().write(JSONUtil.toJsonStr(result));//返回false阻止后续处理 return false;
}
return true;
}
/*** token校验
*
@paramtoken
*
@return */ privateResult check(String body) {

JSONObject jsonObject
=JSONUtil.parseObj(body);
String sign
= "";
Long timestamp
= 0L;//jsonObject 值输入有序列表。 List<String> paramsValueList = new ArrayList<>();
Set
<Map.Entry<String, Object>> entries =jsonObject.entrySet();for (Map.Entry<String, Object>entry : entries) {
String key
=entry.getKey();
Object value
=entry.getValue();if (key.equals("sign")){
sign
=value.toString();continue;
}
if (key.equals("timestamp")){//如果时间戳为空 if(Strings.isNullOrEmpty(value.toString())){return Result.failed(ErrorCodeEnum.ILLEGAL_ARGUMENT.code(), "时间戳不能为空");
}
timestamp
=Long.parseLong(value.toString());
}
paramsValueList.add(value.toString());
}
paramsValueList.add(SecretKey);
Collections.sort(paramsValueList);
//判断时间是否大于5分钟 if (System.currentTimeMillis()/1000 - timestamp >TimestampOut){//return Result.failed(ErrorCodeEnum.ILLEGAL_ARGUMENT.code(), "时间戳无效"); }
String signStr
= "";for(String value : paramsValueList) {
signStr
+=value;
}
log.info(
"signStr:{}", signStr);
String sha1Str
=SecureUtil.sha1(signStr);if(sha1Str.equals(sign)){returnResult.success();
}
return Result.failed(ErrorCodeEnum.ILLEGAL_ARGUMENT.code(), "签名失败");
}

}

/***@authorJJ
* @Classname InterceptorConfig
*/@Configurationpublic class InterceptorConfig implementsWebMvcConfigurer {

@Resource
CheckTokenInterceptor checkTokenInterceptor;
@Resource
CheckSignInterceptor checkSignInterceptor;

@Override
public voidaddInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(checkSignInterceptor).order(
1);

}
}

以上代码基本都是copy的原有代码,没半天就上线了,自以为可以高枕无忧了。

啥也挡不住RPA

上线后,购买了资源包,也提心吊胆的统计着使用量。过了办天,又有几个用户提取了超过70条视频的文案。我一度怀疑签名没生效,直到我看非常规律的调用,我知道了,RPA来了。之前公司买过一个叫影刀RPA软件,也玩了一些时间,编写过一些自动化任务。它可以模拟人操作行为,完成自动化任务,当然,我一直认为未来RPA会有更多业务场景,一些逻辑明确的重复的事,都会由它们来完成。难怪小程序数据里有不少是从pc打开的。我意识到我被薅羊毛了。

无奈只能限量了

本着大家都有机会体验这个小程序的原则,无奈之下,只能给每人每日限量了,毕竟小程序没有收入。再本着能每个人都有极致体验的机会,我限制了每人每天每个功能30次。这下基本上都限制到了,但是看着那些个RPA机器人,一大早就毫无感情的把30次机会耗尽,于是又增加了按UserId配置额度的功能,优先级高于按功能分配的额度。一顿操作后,总算是基本控制住了。又心累又心喜。喜在小程序给部分人带来了价值,即便是用RPA的那些人也是有价值,虽然没有感情。累的是又不得不处理这些烦琐之事。

写在最后

最近短剧火了起来,就有不少人开始提取长视频的文案以及长视频去水印。考虑到微信保存视频时,有个200M的限制,又在考虑支持视频文件压缩功能了。跟本停不下来了,把写代码当成乐趣也是不错的一件事儿。

有兴趣的同学可以扫码体验下小程序(小程序名称正在申请修改名称,建议扫码)

小程序名称 :智能配音实用工具;

小程序二维码 :

前言

在之前的
通过debug搞清楚.vue文件怎么变成.js文件
文章中我们讲过了vue文件是如何编译成js文件,通过那篇文章我们知道了,template编译为render函数底层就是调用了
@vue/compiler-sfc
包暴露出来的
compileTemplate
函数。由于文章篇幅有限,我们没有去深入探索
compileTemplate
函数是如何将template模块编译为
render
函数,在这篇文章中我们来了解一下。

@vue
下面的几个包

先来介绍一下本文中涉及到vue下的几个包,分别是:
@vue/compiler-sfc

@vue/compiler-dom

@vue/compiler-core

  • @vue/compiler-sfc
    :用于编译vue的SFC文件,这个包依赖vue下的其他包,比如
    @vue/compiler-dom

    @vue/compiler-core
    。这个包一般是给
    vue-loader

    @vitejs/plugin-vue
    使用的。

  • @vue/compiler-dom
    :这个包专注于浏览器端的编译,处理浏览器dom相关的逻辑都在这里面。

  • @vue/compiler-core
    :从名字你也能看出来这个包是vue编译部分的核心,提供了通用的编译逻辑,不管是浏览器端还是服务端编译最终都会走到这个包里面来。

先来看个流程图

先来看一下我画的template模块编译为
render
函数这一过程的流程图,让你对整个流程有个大概的印象,后面的内容看着就不费劲了。如下图:
full-progress

从上面的流程图可以看到整个流程可以分为7步:

  • 执行
    @vue/compiler-sfc
    包的
    compileTemplate
    函数,里面会调用同一个包的
    doCompileTemplate
    函数。

  • 执行
    @vue/compiler-sfc
    包的
    doCompileTemplate
    函数,里面会调用
    @vue/compiler-dom
    包中的
    compile
    函数。

  • 执行
    @vue/compiler-dom
    包中的
    compile
    函数,里面会对
    options
    进行了扩展,塞了一些处理dom的转换函数进去。分别塞到了
    options.nodeTransforms
    数组和
    options.directiveTransforms
    对象中。然后以扩展后的
    options
    去调用
    @vue/compiler-core
    包的
    baseCompile
    函数。

  • 执行
    @vue/compiler-core
    包的
    baseCompile
    函数,在这个函数中主要分为4部分。第一部分为检查传入的source是不是html字符串,如果是就调用同一个包下的
    baseParse
    函数生成
    模版AST抽象语法树
    。否则就直接使用传入的
    模版AST抽象语法树
    。此时node节点中还有
    v-for

    v-model
    等指令。这里的
    模版AST抽象语法树
    结构和template模块中的代码结构是一模一样的,所以说
    模版AST抽象语法树
    就是对template模块中的结构进行描述。

  • 第二部分为执行
    getBaseTransformPreset
    函数拿到
    @vue/compiler-core
    包中内置的
    nodeTransforms

    directiveTransforms
    转换函数。

  • 第三部分为将传入的
    options.nodeTransforms

    options.directiveTransforms
    分别和本地的
    nodeTransforms

    directiveTransforms
    进行合并得到一堆新的转换函数,和
    模版AST抽象语法树
    一起传入到
    transform
    函数中执行,就会得到转换后的
    javascript AST抽象语法树
    。在这一过程中
    v-for

    v-model
    等指令已经被转换函数给处理了。得到的
    javascript AST抽象语法树
    的结构和将要生成的
    render
    函数的结构是一模一样的,所以说
    javascript AST抽象语法树
    就是对
    render
    函数的结构进行描述。

  • 第四部分为由于已经拿到了和render函数的结构一模一样的
    javascript AST抽象语法树
    ,只需要在
    generate
    函数中遍历
    javascript AST抽象语法树
    进行字符串拼接就可以得到
    render
    函数了。

@vue/compiler-sfc
包的
compileTemplate
函数

还是同样的套路,我们通过debug一个简单的demo来搞清楚
compileTemplate
函数是如何将template编译成render函数的。demo代码如下:

<template>
  <input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

<script setup lang="ts">
import { ref } from "vue";

const msgList = ref([
  {
    id: 1,
    value: "",
  },
  {
    id: 2,
    value: "",
  },
  {
    id: 3,
    value: "",
  },
]);
</script>

通过debug搞清楚.vue文件怎么变成.js文件
文章中我们已经知道了在使用vite的情况下template编译为render函数是在node端完成的。所以我们需要启动一个
debug
终端,才可以在node端打断点。这里以vscode举例,首先我们需要打开终端,然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

compileTemplate
函数在
node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
文件中,找到
compileTemplate
函数打上断点,然后在
debug
终端中执行
yarn dev
(这里是以
vite
举例)。在浏览器中访问
http://localhost:5173/
,此时断点就会走到
compileTemplate
函数中了。在我们这个场景中
compileTemplate
函数简化后的代码非常简单,代码如下:

function compileTemplate(options) {
  return doCompileTemplate(options);
}

@vue/compiler-sfc
包的
doCompileTemplate
函数

我们接着将断点走进
doCompileTemplate
函数中,看看里面的代码是什么样的,简化后的代码如下:

import * as CompilerDOM from '@vue/compiler-dom'

function doCompileTemplate({
  source,
  ast: inAST,
  compiler
}) {
  const defaultCompiler = CompilerDOM;
  compiler = compiler || defaultCompiler;
  let { code, ast, preamble, map } = compiler.compile(inAST || source, {
    // ...省略传入的options
  });
  return { code, ast, preamble, source, errors, tips, map };
}


doCompileTemplate
函数中代码同样也很简单,我们在debug终端中看看
compiler

source

inAST
这三个变量的值是长什么样的。如下图:
doCompileTemplate

从上图中我们可以看到此时的
compiler
变量的值为
undefined

source
变量的值为template模块中的代码,
inAST
的值为由template模块编译而来的AST抽象语法树。不是说好的要经过
parse
函数处理后才会得到AST抽象语法树,为什么这里就已经有了AST抽象语法树?不要着急接着向下看,后面我会解释。

由于这里的
compiler
变量的值为
undefined
,所以
compiler
会被赋值为
CompilerDOM
。而
CompilerDOM
就是
@vue/compiler-dom
包中暴露的所有内容。执行
compiler.compile
函数,就是执行
@vue/compiler-dom
包中的
compile
函数。
compile
函数接收的第一个参数为
inAST || source
,从这里我们知道第一个参数既可能是AST抽象语法树,也有可能是template模块中的html代码字符串。
compile
函数的返回值对象中的
code
字段就是编译好的
render
函数,然后return出去。

@vue/compiler-dom
包中的
compile
函数

我们接着将断点走进
@vue/compiler-dom
包中的
compile
函数,发现代码同样也很简单,简化后的代码如下:

import {
  baseCompile,
} from '@vue/compiler-core'

function compile(src, options = {}) {
  return baseCompile(
    src,
    Object.assign({}, parserOptions, options, {
      nodeTransforms: [
        ...DOMNodeTransforms,
        ...options.nodeTransforms || []
      ],
      directiveTransforms: shared.extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {}
      )
    })
  );
}

从上面的代码中可以看到这里的
compile
函数也不是具体实现的地方,在这里调用的是
@vue/compiler-core
包的
baseCompile
函数。看到这里你可能会有疑问,为什么不在上一步的
doCompileTemplate
函数中直接调用
@vue/compiler-core
包的
baseCompile
函数,而是要从
@vue/compiler-dom
包中绕一圈再来调用呢
baseCompile
函数呢?

答案是
baseCompile
函数是一个处于
@vue/compiler-core
包中的API,而
@vue/compiler-core
可以运行在各种 JavaScript 环境下,比如浏览器端、服务端等各个平台。
baseCompile
函数接收这些平台专有的一些options,而我们这里的demo是浏览器平台。所以才需要从
@vue/compiler-dom
包中绕一圈去调用
@vue/compiler-core
包中的
baseCompile
函数传入一些浏览器中特有的options。在上面的代码中我们看到使用
DOMNodeTransforms
数组对
options
中的
nodeTransforms
属性进行了扩展,使用
DOMDirectiveTransforms
对象对
options
中的
directiveTransforms
属性进行了扩展。

我们先来看看
DOMNodeTransforms
数组:

const DOMNodeTransforms = [
  transformStyle
];

options
对象中的
nodeTransforms
属性是一个数组,里面包含了许多
transform
转换函数用于处理AST抽象语法树。经过
@vue/compiler-dom

compile
函数处理后
nodeTransforms
数组中多了一个处理style的
transformStyle
函数。这里的
transformStyle
是一个转换函数用于处理
dom
上面的style,比如
style="color: red"

我们再来看看
DOMDirectiveTransforms
对象:

const DOMDirectiveTransforms = {
  cloak: compilerCore.noopDirectiveTransform,
  html: transformVHtml,
  text: transformVText,
  model: transformModel,
  on: transformOn,
  show: transformShow
};

options
对象中的
directiveTransforms
属性是一个对象,经过
@vue/compiler-dom

compile
函数处理后
directiveTransforms
对象中增加了处理
v-cloak

v-html

v-text

v-model

v-on

v-show
等指令的
transform
转换函数。很明显我们这个demo中
input
标签上面的
v-model
指令就是由这里的
transformModel
转换函数处理。

你发现了没,不管是
nodeTransforms
数组还是
directiveTransforms
对象,增加的
transform
转换函数都是处理dom相关的。经过
@vue/compiler-dom

compile
函数处理后,再调用
baseCompile
函数就有了处理dom相关的转换函数了。

@vue/compiler-core
包的
baseCompile
函数

继续将断点走进
vue/compiler-core
包的
baseCompile
函数,简化后的
baseCompile
函数代码如下:

function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  const ast = isString(source) ? baseParse(source, options) : source

  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()

  transform(
    ast,
    Object.assign({}, options, {
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []), // user transforms
      ],
      directiveTransforms: Object.assign(
        {},
        directiveTransforms,
        options.directiveTransforms || {}, // user transforms
      ),
    }),
  )

  return generate(ast, options)
}

我们先来看看
baseCompile
函数接收的参数,第一个参数为
source
,类型为
string | RootNode
。这句话的意思是接收的
source
变量可能是html字符串,也有可能是html字符串编译后的AST抽象语法树。再来看看第二个参数
options
,我们这里只关注
options.nodeTransforms
数组属性和
options.directiveTransforms
对象属性,这两个里面都是存了一堆转换函数,区别就是一个是数组,一个是对象。

我们再来看看返回值类型
CodegenResult
,定义如下:

interface CodegenResult {
  code: string
  preamble: string
  ast: RootNode
  map?: RawSourceMap
}

从类型中我们可以看到返回值对象中的
code
属性就是编译好的
render
函数,而这个返回值就是最后调用
generate
函数返回的。

明白了
baseCompile
函数接收的参数和返回值,我们再来看函数内的代码。主要分为四块内容:

  • 拿到由html字符串转换成的AST抽象语法树。

  • 拿到由一堆转换函数组成的
    nodeTransforms
    数组,和拿到由一堆转换函数组成的
    directiveTransforms
    对象。

  • 执行
    transform
    函数,使用合并后的
    nodeTransforms
    中的所有转换函数处理AST抽象语法树中的所有node节点,使用合并后的
    directiveTransforms
    中的转换函数对会生成props的指令进行处理,得到处理后的
    javascript AST抽象语法树

  • 调用
    generate
    函数根据上一步处理后的
    javascript AST抽象语法树
    进行字符串拼接,拼成
    render
    函数。

获取AST抽象语法树

我们先来看第一块的内容,代码如下:

const ast = isString(source) ? baseParse(source, options) : source

如果传入的
source
是html字符串,那就调用
baseParse
函数根据html字符串生成对应的AST抽象语法树,如果传入的就是AST抽象语法树那么就直接赋值给
ast
变量。为什么这里有这两种情况呢?

原因是
baseCompile
函数可以被直接调用,也可以像我们这样由vite的
@vitejs/plugin-vue
包发起,经过层层调用后最终执行
baseCompile
函数。在我们这个场景中,在前面我们就知道了走进
compileTemplate
函数之前就已经有了编译后的AST抽象语法树,所以这里不会再调用
baseParse
函数去生成AST抽象语法树了。那么又是什么时候生成的AST抽象语法树呢?

在之前的
通过debug搞清楚.vue文件怎么变成.js文件
文章中我们讲了调用
createDescriptor
函数会将
vue
代码字符串转换为
descriptor
对象,
descriptor
对象中拥有
template
属性、
scriptSetup
属性、
styles
属性,分别对应vue文件中的
template
模块、
<script setup>
模块、
<style>
模块。如下图:
progress-createDescriptor
createDescriptor
函数在生成
template
属性的时候底层同样也会调用
@vue/compiler-core
包的
baseParse
函数,将template模块中的html字符串编译为AST抽象语法树。

所以在我们这个场景中走到
baseCompile
函数时就已经有了AST抽象语法树了,其实底层都调用的是
@vue/compiler-core
包的
baseParse
函数。

获取转换函数

接着将断点走到第二块内容处,代码如下:

const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()

从上面的代码可以看到
getBaseTransformPreset
函数的返回值是一个数组,对返回的数组进行解构,数组的第一项赋值给
nodeTransforms
变量,数组的第二项赋值给
directiveTransforms
变量。

将断点走进
getBaseTransformPreset
函数,代码如下:

function getBaseTransformPreset() {
  return [
    [
      transformOnce,
      transformIf,
      transformMemo,
      transformFor,
      transformFilter,
      trackVForSlotScopes,
      transformExpression
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel
    }
  ];
}

从上面的代码中不难看出由
getBaseTransformPreset
函数的返回值解构出来的
nodeTransforms
变量是一个数组,数组中包含一堆transform转换函数,比如处理
v-once

v-if

v-memo

v-for
等指令的转换函数。很明显我们这个demo中
input
标签上面的
v-for
指令就是由这里的
transformFor
转换函数处理。

同理由
getBaseTransformPreset
函数的返回值解构出来的
directiveTransforms
变量是一个对象,对象中包含处理
v-on

v-bind

v-model
指令的转换函数。

经过这一步的处理我们就拿到了由一系列转换函数组成的
nodeTransforms
数组,和由一系列转换函数组成的
directiveTransforms
对象。看到这里我想你可能有一些疑问,为什么
nodeTransforms
是数组,
directiveTransforms
却是对象呢?为什么有的指令转换转换函数是在
nodeTransforms
数组中,有的却是在
directiveTransforms
对象中呢?别着急,我们下面会讲。

transform
函数

接着将断点走到第三块内容,
transform
函数处,代码如下:

transform(
  ast,
  Object.assign({}, options, {
    nodeTransforms: [
      ...nodeTransforms,
      ...(options.nodeTransforms || []), // user transforms
    ],
    directiveTransforms: Object.assign(
      {},
      directiveTransforms,
      options.directiveTransforms || {}, // user transforms
    ),
  }),
)

调用
transform
函数时传入了两个参数,第一个参数为当前的AST抽象语法树,第二个参数为传入的
options
,在
options
中我们主要看两个属性:
nodeTransforms
数组和
directiveTransforms
对象。

nodeTransforms
数组由两部分组成,分别是上一步拿到的
nodeTransforms
数组,和之前在
options.nodeTransforms
数组中塞进去的转换函数。

directiveTransforms
对象就不一样了,如果上一步拿到的
directiveTransforms
对象和
options.directiveTransforms
对象拥有相同的key,那么后者就会覆盖前者。以我们这个例子举例:在上一步中拿到的
directiveTransforms
对象中有key为
model
的处理
v-model
指令的转换函数,但是我们在
@vue/compiler-dom
包中的
compile
函数同样也给
options.directiveTransforms
对象中塞了一个key为
model
的处理
v-model
指令的转换函数。那么
@vue/compiler-dom
包中的
v-model
转换函数就会覆盖上一步中定义的
v-model
转换函数,那么
@vue/compiler-core
包中
v-model
转换函数是不是就没用了呢?答案是当然有用,在
@vue/compiler-dom
包中的
v-model
转换函数会手动调用
@vue/compiler-core
包中
v-model
转换函数。这样设计的目的是对于一些指令的处理支持不同的平台传入不同的转换函数,并且在这些平台中也可以手动调用
@vue/compiler-core
包中提供的指令转换函数,根据手动调用的结果再针对各自平台进行一些特别的处理。

我们先来回忆一下前面demo中的代码:

<template>
  <input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

<script setup lang="ts">
import { ref } from "vue";

const msgList = ref([
  {
    id: 1,
    value: "",
  },
  {
    id: 2,
    value: "",
  },
  {
    id: 3,
    value: "",
  },
]);
</script>

接着在debug终端中看看执行
transform
函数前的AST抽象语法树是什么样的,如下图:
AST

从上图中可以看到AST抽象语法树根节点下面只有一个children节点,这个children节点对应的就是input标签。在input标签上面有三个props,分别对应的是input标签上面的
v-for
指令、
:key
属性、
v-model
指令。说明在生成AST抽象语法树的阶段不会对指令进行处理,而是当做普通的属性一样使用正则匹配出来,然后塞到props数组中。

既然在生成AST抽象语法树的过程中没有对
v-model

v-for
等指令进行处理,那么又是在什么时候处理的呢?答案是在执行
transform
函数的时候处理的,在
transform
函数中会递归遍历整个AST抽象语法树,在遍历每个node节点时都会将
nodeTransforms
数组中的所有转换函数按照顺序取出来执行一遍,在执行时将当前的node节点和上下文作为参数传入。经过
nodeTransforms
数组中全部的转换函数处理后,vue提供的许多内置指令、语法糖、内置组件等也就被处理了,接下来只需要执行
generate
函数生成
render
函数就行了。

nodeTransforms
数组

nodeTransforms
主要是对 node节点 进行操作,可能会替换或者移动节点。每个node节点都会将
nodeTransforms
数组中的转换函数按照顺序全部执行一遍,比如处理
v-if
指令的
transformIf
转换函数就要比处理
v-for
指令的
transformFor
函数先执行。所以
nodeTransforms
是一个数组,而且数组中的转换函数的顺序还是有讲究的。

在我们这个demo中input标签上面的
v-for
指令是由
nodeTransforms
数组中的
transformFor
转换函数处理的,很简单就可以找到
transformFor
转换函数。在函数开始的地方打一个断点,代码就会走到这个断点中,在debug终端上面看看此时的
node
节点是什么样的,如下图:
before-transformFor

从上图中可以看到在执行
transformFor
转换函数之前的node节点和上一张图打印的node节点是一样的。

我们在执行完
transformFor
转换函数的地方打一个断点,看看执行完
transformFor
转换函数后node节点变成什么样了,如下图:
after-transformFor

从上图我们可以看到经过
transformFor
转换函数处理后当前的node节点已经变成了一个新的node节点,而原来的input的node节点变成了这个节点的children子节点。新节点的
source.content
里存的是
v-for="item in msgList"
中的
msgList
变量。新节点的
valueAlias.content
里存的是
v-for="item in msgList"
中的
item
。我们发现input子节点的props数组现在只有两项了,原本的
v-for
指令的props经过
transformFor
转换函数的处理后已经被消费掉了,所以就只有两项了。

看到这里你可能会有疑问,为什么执行
transform
函数后会将AST抽象语法树的结构都改变了呢?

这样做的目的是在后续的
generate
函数中递归遍历AST抽象语法树时,只想进行字符串拼接就可以拼成render函数。这里涉及到
模版AST抽象语法树

Javascript AST抽象语法树
的概念。

我们来回忆一下template模块中的代码:

<template>
<input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

template模版经过
parse
函数拿到AST抽象语法树,此时的AST抽象语法树的结构和template模版的结构是一模一样的,所以我们称之为
模版AST抽象语法树

模版AST抽象语法树
其实就是描述
template
模版的结构。如下图:
template-AST

我们再来看看生成的
render
函数的代码:

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(true), _createElementBlock(
    _Fragment,
    null,
    _renderList($setup.msgList, (item) => {
      return _withDirectives((_openBlock(), _createElementBlock("input", {
        key: item.id,
        "onUpdate:modelValue": ($event) => item.value = $event
      }, null, 8, _hoisted_1)), [
        [_vModelText, item.value]
      ]);
    }),
    128
    /* KEYED_FRAGMENT */
  );
}

很明显
模版AST抽象语法树
无法通过简单的字符串拼接就可以拼成上面的
render
函数,所以我们需要一个结构和上面的render函数一模一样的
Javascript AST抽象语法树

Javascript AST抽象语法树
的作用就是描述
render
函数的结构。如下图:
javascript-AST

上面这个
Javascript AST抽象语法树
就是执行
transform
函数时根据
模版AST抽象语法树
生成的。有了
Javascript AST抽象语法树
后再来执行
generate
函数时就可以只进行简单的字符串拼接,就能得到
render
函数了。

directiveTransforms
对象

directiveTransforms
对象的作用是对指令进行转换,给
node
节点生成对应的
props
。比如给子组件上面使用了
v-model
指令,经过
directiveTransforms
对象中的
transformModel
转换函数处理后,
v-mode
节点上面就会多两个props属性:
modelValue

onUpdate:modelValue
属性。
directiveTransforms
对象中的转换函数不会每次都全部执行,而是要node节点中有对应的指令,才会执行指令的转换函数。所以
directiveTransforms
是对象,而不是数组。

那为什么有的指令转换函数在
directiveTransforms
对象中,有的又在
nodeTransforms
数组中呢?

答案是在
directiveTransforms
对象中的指令全部都是会给node节点生成props属性的,那些不生成props属性的就在
nodeTransforms
数组中。

很容易就可以找到
@vue/compiler-dom
包的
transformModel
函数,然后打一个断点,让断点走进
transformModel
函数中,如下图:
transformModel

从上面的图中我们可以看到在
@vue/compiler-dom
包的
transformModel
函数中会调用
@vue/compiler-core
包的
transformModel
函数,拿到返回的
baseResult
对象后再一些其他操作后直接
return baseResult
。从左边的call stack调用栈中我们可以看到
transformModel
函数是由一个
buildProps
函数调用的,看名字你应该猜到了
buildProps
函数的作用是生成props属性的。点击Step Out将断点跳出
transformModel
函数,走进
buildProps
函数中,可以看到
buildProps
函数中调用
transformModel
函数的代码如下图:
buildProps

从上图中可以看到,
name
变量的值为
model

context.directiveTransforms[name]
的返回值就是
transformModel
函数,所以执行
directiveTransform(prop, node, context)
其实就是在执行
transformModel
函数。在debug终端中可以看到返回的
props2
是一个数组,里面存的是
v-model
指令被处理后生成的props属性。props属性数组中只有一项是
onUpdate:modelValue
属性,看到这里有的小伙伴会疑惑了
v-model
指令不是会生成
modelValue

onUpdate:modelValue
两个属性,为什么这里只有一个呢?答案是只有给自定义组件上面使用
v-model
指令才会生成
modelValue

onUpdate:modelValue
两个属性,对于这种原生input标签是不需要生成
modelValue
属性的,因为input标签本身是不接收名为
modelValue
属性,接收的是value属性。

其实
transform
函数中的内容是非常复杂的,里面包含了vue提供的指令、filter、slot等功能的处理逻辑。
transform
函数的设计高明之处就在于插件化,将处理这些功能的transform转换函数以插件的形式插入的,这样逻辑就会非常清晰了。比如我想看
v-model
指令是如何实现的,我只需要去看对应的
transformModel
转换函数就行了。又比如哪天vue需要实现一个
v-xxx
指令,要实现这个指令只需要增加一个
transformXxx
的转换函数就行了。

generate
函数

经过上一步
transform
函数的处理后,已经将描述模版结构的
模版AST抽象语法树
转换为了描述
render
函数结构的
Javascript AST抽象语法树
。在前面我们已经讲过了
Javascript AST抽象语法树
就是描述了最终生成
render
函数的样子。所以在
generate
函数中只需要递归遍历
Javascript AST抽象语法树
,通过字符串拼接的方式就可以生成
render
函数了。

将断点走到执行
generate
函数前,看看这会儿的
Javascript AST抽象语法树
是什么样的,如下图:
before-generate

从上面的图中可以看到
Javascript AST

模版AST
的区别主要有两个:

  • node节点中多了一个
    codegenNode
    属性,这个属性中存了许多node节点信息,比如
    codegenNode.props
    中就存了
    key

    onUpdate:modelValue
    属性的信息。在
    generate
    函数中遍历每个node节点时就会读取这个
    codegenNode
    属性生成
    render
    函数

  • 模版AST
    中根节点下面的children节点就是input标签,但是在这里
    Javascript AST
    中却是根节点下面的children节点,再下面的children节点才是input标签。多了一层节点,在前面的
    transform
    函数中我们已经讲了多的这层节点是由
    v-for
    指令生成的,用于给
    v-for
    循环出来的多个节点当父节点。

将断点走到
generate
函数执行之后,可以看到已经生成
render
函数啦,如下图:
after-generate

总结

现在我们再来看看最开始讲的流程图,我想你应该已经能将整个流程串起来了。如下图:
full-progress

将template编译为render函数可以分为7步:

  • 执行
    @vue/compiler-sfc
    包的
    compileTemplate
    函数,里面会调用同一个包的
    doCompileTemplate
    函数。这一步存在的目的是作为一个入口函数给外部调用。

  • 执行
    @vue/compiler-sfc
    包的
    doCompileTemplate
    函数,里面会调用
    @vue/compiler-dom
    包中的
    compile
    函数。这一步存在的目的是入口函数的具体实现。

  • 执行
    @vue/compiler-dom
    包中的
    compile
    函数,里面会对
    options
    进行了扩展,塞了一些处理dom的转换函数进去。给
    options.nodeTransforms
    数组中塞了处理style的转换函数,和给
    options.directiveTransforms
    对象中塞了处理
    v-cloak

    v-html

    v-text

    v-model

    v-on

    v-show
    等指令的转换函数。然后以扩展后的
    options
    去调用
    @vue/compiler-core
    包的
    baseCompile
    函数。

  • 执行
    @vue/compiler-core
    包的
    baseCompile
    函数,在这个函数中主要分为4部分。第一部分为检查传入的source是不是html字符串,如果是就调用同一个包下的
    baseParse
    函数生成
    模版AST抽象语法树
    。否则就直接使用传入的
    模版AST抽象语法树
    。此时node节点中还有
    v-for

    v-model
    等指令,并没有被处理掉。这里的
    模版AST抽象语法树
    的结构和template中的结构一模一样,
    模版AST抽象语法树
    是对template中的结构进行描述。

  • 第二部分为执行
    getBaseTransformPreset
    函数拿到
    @vue/compiler-core
    包中内置的
    nodeTransforms

    directiveTransforms
    转换函数。
    nodeTransforms
    数组中的为一堆处理node节点的转换函数,比如处理
    v-on
    指令的
    transformOnce
    转换函数、处理
    v-if
    指令的
    transformIf
    转换函数。
    directiveTransforms
    对象中存的是对一些“会生成props的指令”进行转换的函数,用于给
    node
    节点生成对应的
    props
    。比如处理
    v-model
    指令的
    transformModel
    转换函数。

  • 第三部分为将传入的
    options.nodeTransforms

    options.directiveTransforms
    分别和本地的
    nodeTransforms

    directiveTransforms
    进行合并得到一堆新的转换函数。其中由于
    nodeTransforms
    是数组,所以在合并的过程中会将
    options.nodeTransforms

    nodeTransforms
    中的转换函数全部合并进去。由于
    directiveTransforms
    是对象,如果
    directiveTransforms
    对象和
    options.directiveTransforms
    对象拥有相同的key,那么后者就会覆盖前者。然后将合并的结果和
    模版AST抽象语法树
    一起传入到
    transform
    函数中执行,就可以得到转换后的
    javascript AST抽象语法树
    。在这一过程中
    v-for

    v-model
    等指令已经被转换函数给处理了。得到的
    javascript AST抽象语法树
    的结构和render函数的结构一模一样,
    javascript AST抽象语法树
    就是对
    render
    函数的结构进行描述。

  • 第四部分为由于已经拿到了和render函数的结构一模一样的
    javascript AST抽象语法树
    ,只需要在
    generate
    函数中遍历
    javascript AST抽象语法树
    进行字符串拼接就可以得到
    render
    函数了。

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