RISC- Compiler Reordering- template-compiler分析
RISC- Compiler Reordering- template-compiler分析
参考文献链接
https://mp.weixin.qq.com/s/0O7BFr6Jh1JTuK7d-icRsA
https://mp.weixin.qq.com/s/ZLS317bjT3teuzE_ELpgGA
https://mp.weixin.qq.com/s/mh2P34tdkRaYMZ9I9yoztA
精简指令集集(RISC)处理器
一般来说,人类历史第一台RISC 电脑,应当是CDC6600,姑且不论IBM 力图自我宣传的立场,IBM 801 研发过程逐步厘清RISC 该有的样貌,也是不争的事实。最起码IBM 801 研发团队明确指出「存储器载入/回存(Load / Store) 架构」与「微码不好(Microcode is bad),不要微码」这些历史性结论。
创造「根据代码流可分成指令(Instruction) 和数据(Data) 两种、据此再分成四种计算机类型(SISD、SIMD、MISD、MIMD)」的「费林分类法」(Flynn′s Taxonomy)的Michael Flynn,认定IBM 801 是世界第一个RISC 系统。IBM 前述ACS 计划累积的成果,如高级语言编译器的最佳化手段和更精练的指令集架构,一并延续到IBM 801。
▲ 第一台RISC计算机IBM 801,由IBM 研究员John Cocke 和团队于1970 年代后期设计,他也在1987 年获得计算机工业最高荣誉「图灵奖」。1974 年,IBM 开始研究如何打造每小时处理100 万则通话、平均每秒300 通的电话交换机。假如每通电话须执行2万个指令,代表每秒最少要600 万个指令(6MIPS),再算额外处理负担,起码要1,200 万个指令(12MIPS)。但当时IBM 销售最高端机种System/370 Model 168(1972 年),只有每秒300 万个指令(3MIPS),这说明要出现巨大技术突破,才有实现目标的可能。因此John Cocke 团队删除所有操作存储器内数据的指令,只保留使用处理器内的数据寄存器。相较未有专门指令负责存储器载入(Load) 和存储器回存(Store)的CDC6600,电话交换机研究案直截了当指明RISC 指令集第一个要素:存储器载入(Load)/回存(Store)架构。白话点就是「一次从存储器抓一堆运算元(数据)进来,算完再一次丢回存储器」。IBM 在1975 年取消这计算机结构的概念取得相当进展的实验性专案,但当年10 月IBM 决定当成不同应用可共享的通用设计,继续发展,并以研发部门所在地Thomas J.Watson 研究中心建筑物编号为名,命名为IBM 801 计划。日后IBM 801 普遍用于各种IBM 产品,包括System/370 大型主机I/O 通道控制器、各种IBM 网通设备、IBM 9370 大型主机的垂直微码(Vertical Microcode)执行单元,并在日后成为IBM ROMP(Resarch OPD Micro Processor)微处理器、IBM RT PC 工作站和几个内部研究案的技术基础,一步步迈向时下高端RISC 之王:Power(Performance Optimization With Enhanced RISC)。接着更伟大的成就,就堂堂登场了──John Cocke 团队发现软件编译程序都未用到大多数透过微码(Microcode)实作的功能强大复杂的指令。当时可能一个看起来很简单的加法指令,就有一堆对应不同运算元(像寄存器与存储器配对,或不同运算元数量)版本,就是CISC 的特色,为了提供充裕指令数量与功能(受大型主机影响,是那时很重要的商业行销诉求),反而拖慢常用的简单指令。抛弃微码,使用硬件线路(Hardwired)制作这些指令,并有效流水线化(Pipeline),就是RISC 的第二个特征。
▲ 微码是区分RISC与CISC 的最根本差异,没有之一。即使后来的RISC…...John Cocke 团队总结,这句话也从此颠覆指令集架构潮流:“在计算机与使用者间强加微码(用微码实作「为软件与硬件界面的指令集架构」),会在执行最频繁执行的指令时,产生昂贵的额外负担。”(Imposing microcode between a computer and its users imposes an expensive overhead in performing the most frequently executed instructions.)RISC的重大精神「让最常用的指令跑的更快」(Make The Common Case Fast)由此而生。
▲ 虽然IBM 并未发明RISC 这名词,但IBM 的801 计划的确早于当代两位大师David Patterson(RISC 名词创造者,之后才出现「对照」用CISC)的RISC I 和John Hennessy 的MIPS。1980 年夏天问世的最初版,时钟频率15.15MHz,理论运算效能高达15MIPS,超出1974 年电话交换机的预定性能需求。但IBM 801 打从娘胎起就是为了功能极有限的系统而生,所以规格也极简单,仅有16 个24 位元数据寄存器,指令编码长度是短短24 位元,没有虚拟存储器,延续古老的2 运算元格式(A = A + B),但2运算元格式(A = A + B) 却会覆盖其中一个,代表必须事先复制一个运算元的数据到另一个寄存器,降低效率。后来IBM 改良多次,特别重要的是将指令编码长度扩展到32 位元,不仅让数据寄存器数量倍增到32 个(用到5 个位元标定),更有足够位元数,改为3 运算元格式(A = B + C),更利于数学应用,因两个数字(B 和C) 都可保留于寄存器,以便重新使用。RISC 第三个象征:「应该」要有32 个数据寄存器和三运算元格式,就这样出现在各位眼前(至于嵌入式导向RISC 指令集,如ARM Thumb,那又是另一个故事了)。更值得注意的是,不限硬件,IBM 801 计划也对改善编译器(Compiler)效率有巨大贡献(各位还记得上一篇ACS 和Frances Allen吗?)。新版IBM 801 于System/370 大型主机以「模拟器」执行时,运行速度竟然会比System/370 原生程式码还快。研发团队将IBM 801「尽其所能利用数据寄存器,并设法减低存取存储器频率」的编译器「逆向」移植回CISC指令集架构的System/370,效能也比原版快三倍,充分证明针对RISC 发展的编译器最佳化技术,也同样可改进CISC 处理器软件执行效能,反正能减少上下其手存储器,都不啻是好事一桩。
总之,从CDC6600、ACS 和IBM 801,能对RISC 做以下总结:1.核心精神:让最常用的指令跑更快。2.RISC「精简」的是指令格式与运算元定址模式,不是指令数目。3.存储器载入(Load)回存(Store)都由专属指令负责。4.尽量使用硬件线路(Hardwired)实做指令,避免采用微码(Microcode)产生控制讯号。5.预设最少要有32个数据寄存器与3 运算元(A = B + C)。6.因应高级语言普及,编译器(Compiler)技术在效能层面扮演举足轻重角色。但IBM 801的传奇并未划下句点,更大挑战即将现身John Cocke 等人眼前:1982 年以IBM 801 的成就为地基,IBM 启动Cheetah(猎豹)计划,借实作多组执行单元,让RISC 在单一时脉周期内执行多道指令,也就是同时执行一个以上指令的「超标量」(Superscalar)流水线,结合让RISC抢滩个人电脑市场的努力(ROMP、RT PC、PowerPC),铺陈蓝色巨人横越超过半世纪的壮丽RISC 发展史。
▲ 讨厌RISC-V 阵营老爱把RISC 说得好像横空出世的伟大发明,就算不提IBM,RISC 起源甚至可追溯至1964年的CDC6600,距今超过半个世纪。如果还没忘记上一篇提到的IBM System/360,就会马上理解服务器的世界,无论CISC 还是RISC,最顶端的高端产品,依旧清一色是IBM 天下。
▲ 无预警停机时间排名,足以代替千言万语。不过论世界第一个「兼具超标量、乱序与预测指令执行的RISC 处理器」,一般认定是1990 年IBM Power1(限浮点运算) 或1993 年IBM PowerPC 601,但其实早在1978 年苏联Elbrus-1 实现,只是苏联解体、冷战结束后才逐渐被世人知悉。笔者更好奇的是,当代两位大师合着的两本经典教科书《计算机体系架构:量化研究方法》,何时会探讨俄国人的计算机领域成就?
编译乱序(Compiler Reordering)
编译器(compiler)的工作就是优化代码以提高性能。这包括在不改变程序行为的情况下重新排列指令。因为compiler不知道什么样的代码需要线程安全(thread-safe),所以compiler假设代码都是单线程执行(single-threaded),并且进行指令重排优化并保证是单线程安全的。因此,当你不需要compiler重新排序指令的时候,你需要显式告诉compiler,我不需要重排。否则,它可不会听你的。本篇文章中,一起探究compiler关于指令重排的优化规则。
注:测试使用aarch64-linux-gnu-gcc版本:7.3.0
编译器指令重排(Compiler Instruction Reordering)
compiler的主要工作就是将对人们可读的源码转化成机器语言,机器语言就是对CPU可读的代码。因此,compiler可以在背后做些不为人知的事情。考虑下面的C语言代码:
- int a, b;
- void foo(void)
- {
- a = b + 1;
- b = 0;
- }
使用aarch64-linux-gnu-gcc在不优化代码的情况下编译上述代码,使用objdump工具查看foo()反汇编结果:
- <foo>:
- ...
- ldr w0, [x0] // load b to w0
- add w1, w0, #0x1
- ...
- str w1, [x0] // a = b + 1
- ...
- str wzr, [x0] // b = 0
应该知道Linux默认编译优化选项是-O2,因此采用-O2优化选项编译上述代码,并反汇编得到如下汇编结果:
- <foo>:
- ...
- ldr w2, [x0] // load b to w2
- str wzr, [x0] // b = 0
- add w0, w2, #0x1
- str w0, [x1] // a = b + 1
- ...
比较优化和不优化的结果,可以发现。在不优化的情况下,a 和 b 的写入内存顺序符合代码顺序(program order)。但是-O2优化后,a 和 b 的写入顺序和program order是相反的。-O2优化后的代码转换成C语言可以看作如下形式:
- int a, b;
- void foo(void)
- {
- register int reg = b;
- b = 0;
- a = reg + 1;
- }
这就是compiler reordering(编译器重排)。为什么可以这么做呢?对于单线程来说,a 和 b 的写入顺序,compiler认为没有任何问题。并且最终的结果也是正确的(a == 1 && b == 0)。
这种compiler reordering在大部分情况下是没有问题的。但是在某些情况下可能会引入问题。例如使用一个全局变量flag
标记共享数据data
是否就绪。由于compiler reordering,可能会引入问题。考虑下面的代码(无锁编程):
- int flag, data;
- void write_data(int value)
- {
- data = value;
- flag = 1;
- }
如果compiler产生的汇编代码是flag比data先写入内存。那么,即使是单核系统上,也会有问题。在flag置1之后,data写45之前,系统发生抢占。另一个进程发现flag已经置1,认为data的数据已经准别就绪。但是实际上读取data的值并不是45。为什么compiler还会这么操作呢?因为,compiler是不知道data和flag之间有严格的依赖关系。这种逻辑关系是人为强加的。如何避免这种优化呢?
显式编译器屏障(Explicit Compiler Barriers)
为了解决上述变量之间存在依赖关系导致compiler错误优化。compiler为提供了编译器屏障(compiler barriers),可用来告诉compiler不要reorder。继续使用上面的foo()函数作为演示实验,在代码之间插入compiler barriers。
- #define barrier() __asm__ __volatile__("": : :"memory")
- int a, b;
- void foo(void)
- {
- a = b + 1;
- barrier();
- b = 0;
- }
barrier()就是compiler提供的屏障,作用是告诉compiler内存中的值已经改变,之前对内存的缓存(缓存到寄存器)都需要抛弃,barrier()之后的内存操作需要重新从内存load,而不能使用之前寄存器缓存的值。并且可以防止compiler优化barrier()前后的内存访问顺序。barrier()就像是代码中的一道不可逾越的屏障,barrier前的 load/store 操作不能跑到barrier后面;同样,barrier后面的 load/store 操作不能在barrier之前。依然使用-O2优化选项编译上述代码,反汇编得到如下结果:
- <foo>:
- ...
- ldr w2, [x0] // load b to w2
- add w2, w2, #0x1
- str w2, [x1] // a = a + 1
- str wzr, [x0] // b = 0
- ...
可以看到插入compiler barriers之后,a 和 b 的写入顺序和program order一致。因此,当代码中需要严格的内存顺序,就需要考虑compiler barriers。
隐式编译器屏障(Implied Compiler Barriers)
除了显示的插入compiler barriers之外,还有别的方法阻止compiler reordering。例如CPU barriers 指令,同样会阻止compiler reordering。后续再考虑CPU barriers。
除此以外,当某个函数内部包含compiler barriers时,该函数也会充当compiler barriers的作用。即使这个函数被inline,也是这样。例如上面插入barrier()的foo()函数,当其他函数调用foo()时,foo()就相当于compiler barriers。考虑下面的代码:
- int a, b, c;
- void fun(void)
- {
- c = 2;
- barrier();
- }
- void foo(void)
- {
- a = b + 1;
- fun(); /* fun() call act as compiler barriers */
- b = 0;
- }
fun()函数包含barrier(),因此foo()函数中fun()调用也表现出compiler barriers的作用。同样可以保证 a 和 b 的写入顺序。如果fun()函数不包含barrier(),结果又会怎么样呢?实际上,大多数的函数调用都表现出compiler barriers的作用。但是,这不包含inline的函数。因此,fun()如果被inline进foo(),那么fun()就不会具有compiler barriers的作用。如果被调用的函数是一个外部函数,其副作用会比compiler barriers还要强。因为compiler不知道函数的副作用是什么。它必须忘记它对内存所作的任何假设,即使这些假设对该函数可能是可见的。我么看一下下面的代码片段,printf()一定是一个外部的函数。
- int a, b;
- void foo(void)
- {
- a = 5;
- printf("smcdef");
- b = a;
- }
同样使用-O2优化选项编译代码,objdump反汇编得到如下结果。
- <foo>:
- ...
- mov w2, #0x5 // #5
- str w2, [x19] // a = 5
- bl 640 <__printf_chk@plt> // printf()
- ldr w1, [x19] // reload a to w1
- ...
- str w1, [x0] // b = a
compiler不能假设printf()不会使用或者修改 a 变量。因此在调用printf()之前会将 a 写5,以保证printf()可能会用到新值。在printf()调用之后,重新从内存中load a 的值,然后赋值给变量 b。重新load a 的原因是compiler也不知道printf()会不会修改 a 的值。
因此,可以看到即使存在compiler reordering,但是还是有很多限制。当需要考虑compiler barriers时,一定要显示的插入barrier(),而不是依靠函数调用附加的隐式compiler barriers。因为,谁也无法保证调用的函数不会被compiler优化成inline方式。
barrier()除了防止编译乱序,还没能做什么
barriers()作用除了防止compiler reordering之外,还有什么妙用吗?考虑下面的代码片段。
- int run = 1;
- void foo(void)
- {
- while (run)
- ;
- }
run是个全局变量,foo()在一个进程中执行,一直循环。期望的结果时foo()一直等到其他进程修改run的值为0才推出循环。实际compiler编译的代码和会达到预期的结果吗?看一下汇编代码。
- 0000000000000748 <foo>:
- 748: 90000080 adrp x0, 10000
- 74c: f947e800 ldr x0, [x0, #4048]
- 750: b9400000 ldr w0, [x0] // load run to w0
- 754: d503201f nop
- 758: 35000000 cbnz w0, 758 <foo+0x10> // if (w0) while (1);
- 75c: d65f03c0 ret
汇编代码可以转换成如下的C语言形式。
- int run = 1;
- void foo(void)
- {
- register int reg = run;
- if (reg)
- while (1)
- ;
- }
compiler首先将run加载到一个寄存器reg中,然后判断reg是否满足循环条件,如果满足就一直循环。但是循环过程中,寄存器reg的值并没有变化。因此,即使其他进程修改run的值为0,也不能使foo()退出循环。很明显,这不是想要的结果。继续看一下加入barrier()后的结果。
- 0000000000000748 <foo>:
- 748: 90000080 adrp x0, 10000
- 74c: f947e800 ldr x0, [x0, #4048]
- 750: b9400001 ldr w1, [x0] // load run to w0
- 754: 34000061 cbz w1, 760 <foo+0x18>
- 758: b9400001 ldr w1, [x0] // load run to w0
- 75c: 35ffffe1 cbnz w1, 758 <foo+0x10> // if (w0) goto 758
- 760: d65f03c0 ret
可以看到加入barrier()后的结果真是想要的。每一次循环都会从内存中重新load run的值。因此,当有其他进程修改run的值为0的时候,foo()可以正常退出循环。为什么加入barrier()后的汇编代码就是正确的呢?因为barrier()作用是告诉compiler内存中的值已经变化,后面的操作都需要重新从内存load,而不能使用寄存器缓存的值。因此,这里的run变量会从内存重新load,然后判断循环条件。这样,其他进程修改run变量,foo()就可以看得见了。
在Linux kernel中,提供了cpu_relax()函数,该函数在ARM64平台定义如下:
- static inline void cpu_relax(void)
- {
- asm volatile("yield" ::: "memory");
- }
可以看出,cpu_relax()是在barrier()的基础上又插入一条汇编指令yield。在kernel中,经常会看到一些类似上面举例的while循环,循环条件是个全局变量。为了避免上述所说问题,就会在循环中插入cpu_relax()调用。
- int run = 1;
- void foo(void)
- {
- while (run)
- cpu_relax();
- }
当然也可以使用Linux 提供的READ_ONCE()。例如,下面的修改也同样可以达到预期的效果。
- int run = 1;
- void foo(void)
- {
- while (READ_ONCE(run)) /* similar to while (*(volatile int *)&run) */
- ;
- }
当然你也可以修改run的定义为volatile int run
,
就会得到如下代码。同样可以达到预期目的。
- volatile int run = 1;
- void foo(void)
- {
- while (run)
- ;
- }
关于volatile更多使用建议可以参考这里。
解读vue-template-compiler
- 导读
在写Vue 单文件组件的时候,需要知道是是使用vue-loader和vue-template-compiler 对vue文件进行的处理。Vue-loader是webpack的插件,因为Vue2 版本是基于webpack打包的。准备的说,vue-cli底层使用的是webpack。Vue-loader更多的需要去处理静态资源相关的信息,而vue-template-compiler 是则是专门处理vue template 文件的工具,后面的内容则是主要分析vue-template-compiler 中的compiler 过程和实现一个最小化的vue-template-compiler;
- vue-template-compiler 工程分析 当分析一个源码库的时候,面对如此多的代码,首先不能没有头绪,在笔者看来,比较合适的方式是找出口,对于node 环境的库,最后执行导出:exports.xxx = xxx; 这个就是对外暴露的用法,所以第一步找到头,这个头找到了,接下来需要顺藤摸瓜,找到与之相关的完整逻辑,按照这个方法,分析vue-template-compiler:
function createCompilerCreator (baseCompile) {
return function createCompiler (baseOptions) {
function compile (
template,
options
) {
var finalOptions = Object.create(baseOptions);
var errors = [];
var tips = [];
var warn = function (msg, range, tip) {
(tip ? tips : errors).push(msg);
};
if (options) {
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
// $flow-disable-line
var leadingSpaceLength = template.match(/^\s*/)[0].length;
warn = function (msg, range, tip) {
var data = { msg: msg };
if (range) {
if (range.start != null) {
data.start = range.start + leadingSpaceLength;
}
if (range.end != null) {
data.end = range.end + leadingSpaceLength;
}
}
(tip ? tips : errors).push(data);
};
}
// merge custom modules
if (options.modules) {
finalOptions.modules =
(baseOptions.modules || []).concat(options.modules);
}
// merge custom directives
if (options.directives) {
finalOptions.directives = extend(
Object.create(baseOptions.directives || null),
options.directives
);
}
// copy other options
for (var key in options) {
if (key !== 'modules' && key !== 'directives') {
finalOptions[key] = options[key];
}
}
}
finalOptions.warn = warn;
var compiled = baseCompile(template.trim(), finalOptions);
if (process.env.NODE_ENV !== 'production') {
detectErrors(compiled.ast, warn);
}
compiled.errors = errors;
compiled.tips = tips;
return compiled
}
return {
compile: compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
var createCompiler = createCompilerCreator(function baseCompile (
template,
options
) {
var ast = parse(template.trim(), options);
if (options.optimize !== false) {
optimize(ast, options);
}
var code = generate(ast, options);
return {
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
});
var ref = createCompiler(baseOptions);
var compile = ref.compile;
exports.compile = compile;
compiler 找到了,接下来寻找谁创建的compiler , 是createCompiler函数创建的compiler,createCompilerCreator 函数又创建了createCompiler , 所以最终找到了来龙去脉。当前,函数的内部依赖了很多的函数,这里并没有声明定义,所以执行的时候肯定会报错。接下来的工作就需要一步一步将一些判断、边界函数的定义声明到文件中。
这里就不展开这个工作了,至于代码,笔者会在最后补充上。接下来,就需要分析vue-template-compiler 做了哪些事情。
执行了compiler 的基本过程,那么,在继续聚焦,聚焦parse 和 generate 的实现。
parse:
/**
* Convert HTML string to AST.
*/
function parse (
template,
options
) {
warn$1 = options.warn || baseWarn;
platformIsPreTag = options.isPreTag || no;
platformMustUseProp = options.mustUseProp || no;
platformGetTagNamespace = options.getTagNamespace || no;
var isReservedTag = options.isReservedTag || no;
maybeComponent = function (el) { return !!(
el.component ||
el.attrsMap[':is'] ||
el.attrsMap['v-bind:is'] ||
!(el.attrsMap.is ? isReservedTag(el.attrsMap.is) : isReservedTag(el.tag))
); };
transforms = pluckModuleFunction(options.modules, 'transformNode');
preTransforms = pluckModuleFunction(options.modules, 'preTransformNode');
postTransforms = pluckModuleFunction(options.modules, 'postTransformNode');
delimiters = options.delimiters;
var stack = [];
var preserveWhitespace = options.preserveWhitespace !== false;
var whitespaceOption = options.whitespace;
var root;
var currentParent;
var inVPre = false;
var inPre = false;
var warned = false;
function warnOnce (msg, range) {
...
}
function closeElement (element) {
trimEndingWhitespace(element);
if (!inVPre && !element.processed) {
element = processElement(element, options);
}
// tree management
if (!stack.length && element !== root) {
// allow root elements with v-if, v-else-if and v-else
if (root.if && (element.elseif || element.else)) {
if (process.env.NODE_ENV !== 'production') {
checkRootConstraints(element);
}
addIfCondition(root, {
exp: element.elseif,
block: element
});
} else if (process.env.NODE_ENV !== 'production') {
warnOnce(
"Component template should contain exactly one root element. " +
"If you are using v-if on multiple elements, " +
"use v-else-if to chain them instead.",
{ start: element.start }
);
}
}
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) {
processIfConditions(element, currentParent);
} else {
if (element.slotScope) {
// scoped slot
// keep it in the children list so that v-else(-if) conditions can
// find it as the prev node.
var name = element.slotTarget || '"default"'
;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element;
}
currentParent.children.push(element);
element.parent = currentParent;
}
}
// final children cleanup
// filter out scoped slots
element.children = element.children.filter(function (c) { return !(c).slotScope; });
// remove trailing whitespace node again
trimEndingWhitespace(element);
// check pre state
if (element.pre) {
inVPre = false;
}
if (platformIsPreTag(element.tag)) {
inPre = false;
}
// apply post-transforms
for (var i = 0; i < postTransforms.length; i++) {
postTransforms[i](element, options);
}
}
function trimEndingWhitespace (el) {
// remove trailing whitespace node
...
}
function checkRootConstraints (el) {
if (el.tag === 'slot' || el.tag === 'template') {
warnOnce(
"Cannot use <" + (el.tag) + "> as component root element because it may " +
'contain multiple nodes.',
{ start: el.start }
);
}
if (el.attrsMap.hasOwnProperty('v-for')) {
warnOnce(
'Cannot use v-for on stateful component root element because ' +
'it renders multiple elements.',
el.rawAttrsMap['v-for']
);
}
}
});
return root
}
parse 函数的代码有点多,主要的逻辑在于函数parseHTML函数,该函数主要是将HTML代码转换,转换的时候需要注意开关的tag等,也就是说所有的处理都是在该函数上。
parse过程结束之后,接下来是generate过程。该过程主要是将代码规格化【格式化】,便于输出最后的{ast, render, staticRenderFns };
整个过程下来,就是vue-template-compiler 中的compiler 的主要逻辑了。
- mini-vue-template-compiler compiler 实现
compiler 实现的代码见github;
- 结语
因为平时业务用vue 开发比较的多 ,所以会分析一下vue-template-compiler 的实现过程,后续会继续分析vue-loader, 这是孪生兄弟,少不了的。
参考文献链接
https://mp.weixin.qq.com/s/0O7BFr6Jh1JTuK7d-icRsA
https://mp.weixin.qq.com/s/ZLS317bjT3teuzE_ELpgGA
https://mp.weixin.qq.com/s/mh2P34tdkRaYMZ9I9yoztA