我正在参加「掘金·启航计划」
写在前面
小伙伴们,大家好!今天我们谈论一个被许多人视为“空洞无物”的话题——前端编译阶段。
有人说,编译像是一场华丽的魔术,看似神奇,但对日常工作毫无帮助。
然而,就像那些被大师们轻松揭开的魔术秘密一样,前端编译阶段还有着不为人知的奥秘。
在这篇文章中,我们就来探讨编译这个话题。看看前端编译有哪些优秀的应用,以及工作中的应用场景。
此”编译”非彼”编译”
在聊前端编译之前我们先简单瞅瞅,传统计算机领域对编译的定义。
编译是把高级语言编写的源程序转换成机器语言(目标语言)的过程
看着是不是很简单,举几个编译场景的?,
比如,我们写的C语言代码,要用gcc编译器编译成机器码才可以运行;
Java代码被JDK编译成字节码然后在虚拟机运行;
?经过编译变成?。。。。
总之,编译过程可以简化为:
而 JavaScript 是一门解释型语言,只需要解释器环境(目前常见的解释器有浏览器、Node.js、Electron)即可运行,所以其本身也可以不编译。
编译阶段在前端的角色定位
在原始时代,前端前辈们写完代码,测试好就直接上线了,由于代码前后没有变化,所以我们可以通过源码可以和前辈们进行 正能量 的思想交流。
比如有夸赞代码优雅的
有推荐老东家的
还有温馨提示密码的
总之,大家发现这种开发模式太操蛋了啊,
注解的问题暂且不说,
今天为了兼容IE大家不敢用新的JS语法,
明天某某实习生上线了奇怪代码引发bug,
后天某热心市民把公司的核心逻辑公布公之于众。。。
总之,这个时期前端开发真的是狠苦逼
恰好在时代的召唤下 Node.js 横空出世了,让 JavaScript 拥有了服务端运行的能力,
为了解决诸如此类的问题,前端工程化 应运而生。其中一个概念便是”编译”。
在此之前的前端开发流程是这样的:
而现在是这样的:
但与其他编译型语言不同,JavaScript的编译过程是“我编译成我自己”。
是不是想说“离了个大谱”?
No,No,No,这么做确实是有意义的哈,
举个例子:
可以看到,结果其实并没有变,只是压力给到 编译器 ,它会帮我们把右边开发者写的代码转成左边代码。
现在我们体会到前端编译阶段的重要性了,接下来聊聊前端编译的几个代表性方式~
-
最简单的编译:shell脚本
上文提到,编译本质也是程序,所以简单场景可以直接使用 shell
比如:
"scripts": { "build": "cd build && ./compile.sh", },
"scripts": { "build": "tsc index.ts", },
-
简单场景或许可以用shell,但大型项目中可就不好使咯,大部分同学应该对这条方案很熟悉。
像webpack、rollup、vite这些都是第一条方案的完善版,比如当我们执行
vite build
时,其运行的不再是简单脚本,而是一套完整的打包流程。我们可以通过配置文件、插件拓展额外的功能。 -
以 Rust 为代表,其他语言写的编译工具
前面我们聊到,JavaScript是一门 解释型语言,虽然 Node.js 通过即时编译(JIT)技术提高了性能,但相比于可以编译成机器码的高级语言,前者的性能仍无法与后者相媲美。
再者说,编译阶段主要在做文本(代码)转换、合并、拆分这些事,这些都是CPU密集任务,在协程、多线程满天飞的黄金时代,Rust、Go这些语言自然是更胜一筹。
于是很多大佬在大型项目遇到性能瓶颈后就开始考虑向这些编译工具转换了,
虽说是换了语言,但其原理并没有太大变化。所以说,我们平时在用任何工具时都多思考其核心原理,不然将来怎么落伍的都不知道。
从编译原理看前端编译
在编译原理中,整个编译过程包含如下几个阶段:
看上去不好理解哈,否则当初大学时不会那么多人挂科了。。。
完整的编译过程是面向编译型语言和解释型语言的通用模型,但前端工程化将这个流程简化为下图所示
这里我们结合Vue3 Template Compiler看整个编译过程
假设有这样一段模板
<template>
<div>
<img :src="image" class="logo" />
<span @click="add">{{ count }}</span>
</div>
</template>
parse
parse表示解析,意思是将代码解析成特定结构,该阶段做两件事情:词法分析和语法分析。
编译器拿到代码时是一个字符串,我们需要将这个[字符串]
根据[某种特征]
提取关键词。
这里说的[特征]
是指模板的Html结构,是有规律可循的。
在源码中通过正则表达式解析出[标志词]
、创建AST节点,然后截断字符串,循环此过程以实现parse流程。
伪代码如下:
while (source) {
// 1. 词法分析 - 解析Token
const match = START_TAG_REG.exec(source)
if (match) {
const tag = match[1]
source = source.slice(match[0].length)
// 2. 语法分析 - 创建AST
const props = parseAttributes(source)
const node = {
type: NodeTypes.ELEMENT,
tag,
props,
}
}
}
循环结束后,我们得到如下结构(简化)
const ast = {
tag: 'template',
props: [],
children: [
{
tag: 'div',
props: [],
children: [
{
tag: 'img',
props: [
{
name: 'src',
isStatic: false,
value: 'image',
},
{
name: 'class',
isStatic: true,
value: 'logo',
},
],
},
{
tag: 'span',
props: [
{
name: '@click',
isStatic: false,
value: 'add',
},
],
children: [
{
isStatic: false,
content: 'count',
},
],
},
],
},
],
}
transform
transform 意思是转换/优化,简单讲就是优化AST,让他的信息更详细,听起来或许很抽象,但我们可以通过Vue的例子来增强理解,
在Vue3中,编译器在该环节做的优化在这里
我们拿其中一个细节 PatchFlags 来举例增强理解
上面我们得到的AST其中有个节点大致如下:
const AstNode = {
tag: 'img',
props: [
{
name: 'src',
isStatic: false,
value: 'image',
},
{
name: 'class',
isStatic: true,
value: 'logo',
},
],
}
可以看到props
的内容中有isStatic
这个字段,
这个字段用来区分当前属性是否是静态值,
分析属性中的静态值,最后得到一个 PatchFlags,用于描述将来在 diff算法 中需要绕过哪些字段,以提高算法效率。
generate
generate见名知意,该阶段的任务就是生成代码,由于上个环节已经获得了完善的AST,所以现在我们可以根据这个树结构生成字符串形式的代码。
在 Vue编译器 中这里是将 AST 生成 JavaScript代码 。
生成内容大致如下:
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = ["src"]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("template", null, [
_createElementVNode("div", null, [
_createElementVNode("img", {
src: _ctx.image,
class: "logo"
}, null, 8 /* PROPS */, _hoisted_1),
_createElementVNode("span", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.add && _ctx.add(...args)))
}, _toDisplayString(_ctx.count), 1 /* TEXT */)
])
]))
}
总结
大部分前端编译过程都是这三个环节: parse
、 transform
、 generate
也就是 代码转AST,优化AST,再转成代码。
像Babel、TypeScript、ESLint、PostCSS,还有早些年的uglify-js,都是基于这种编译思想。
简单总结下:
-
parse阶段
parse阶段是一个编译器的基础,这个阶段决定了语言的下限,也就是能支持的语法下限,为什么说下限呢,我们编译的不都是JavaScript、CSS这些语法吗,其实也不完全是,像TypeScript、PostCSS就有独创的新语法呀,所以这是基础。
-
transform阶段
transform阶段比较简单,只是循环遍历了AST的所有节点,虽然简单,但其扩展性比较高,大部分编译工具在这个阶段都会使用一种叫做 访问者模式 的设计模式。
简单讲就是AST上每个节点都有自己的类型,比如定义变量、生命函数、判断条件、循环等,没访问到一个类型就执行该类型对应的函数,而这个函数可以由开发者自行提供,所以你会发现babel插件大部分都是这样的结构
module.exports = function(babel) { return { visitor: { FunctionDeclaration(path, state) { // 函数声明语句 ... }, VariableDeclarator(path, state) { // 变量声明语句 ... } } } };
看上去是不是很简单,但复杂的是编译思路和逻辑,这里面的代码一定要很仔细的写,否则很容易判断错条件执行了不该执行的逻辑。
大部分编译工具都在这个阶段作文章,比如Vue3做静态分析、用PatchFlag记录内容;再比如SolidJS、Svelte这类去虚拟Dom的框架,他们选择在这个阶段分析dom操作意图,这些都可以带来大幅度的性能提升。
-
generate阶段
这个阶段同样不复杂,树结构转代码,相信很多开发者都能顺利地写出来,而且大部分场景不会在这个阶段做文章。仅作了解好啦~
在工作中如何用好编译思想
但聊了这么多,貌似只有在框架、工具中有编译的场景,日常开发工作是不是和编译无关了呢?
说归说,闹归闹,实际上当然不是这样啦,工作中很多场景我们可以用编译的思想做,而且效果会更好。
-
自定义ESLint插件
很多项目会用 ESLint 保持统一的开发规范。但市面上的 ESLint插件 只能限制常见的规范。
像笔者所在的公司人员够成复杂,项目也比较多,就需要很多特殊的开发规范,比如:不能全量引用
lodash
等工具库、业务埋点必须加 注解、文档 等等。如果只靠 开发阶段 和 review阶段 人为把控也太容易出错了。
这时候就可以祭出 自定义ESLint插件 啦,配合编辑器的 自动修复机制 简直不要太完美~
-
SDK的API有更新,所有涉及到的项目有几十个,眼睛瞎了都改不完。。。
这时候如果有一款工具自动修改就好了~
这时候就可以祭出jscodeshift啦,
-
国际化的另一种方案
目前大部分国际化的实现方案是把文字和语言的映射方案存储在前端,前端在渲染文字时通过函数将内容转成对应的语言。
如果一个已经成型的项目某天要接入国际化,无疑这是一个很大的改动。这时候可以在编译环节作文章。
以 Vite+Vue 项目为例,一般都会有 @vitejs/plugin-vue 这个插件,这个插件就用到了我们上面聊到的 Vue SFC Compiler,我们可以在插件编译 Vue Template 时通过 访问者模式 找到要修改的 AST节点 ,把具体的字面量替换成函数调用的方式。
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' // https://vitejs.dev/config/ export default defineConfig({ resolve: { extensions: ['.vue'], }, plugins: [ vue({ template: { compilerOptions: { nodeTransforms: [ node => { // ... 操作AST }, ], }, }, }), ], })
-
一套代码,多端渲染
老板:这个H5应用投放效果不错,我们在小程序端也上线一个吧,反正代码都有,3天搞定吧,
这时候别急着掀桌子,mpvue-loader、taro-loader这些都可以用来输出小程序,最后简单改改就好啦~
-
代码生成
这个应用还是比较广的,比如”根据文档自动生成类型和接口代码”,
后端接口生成的yapi或者swagger文档,本质上都是json,
可以将json生成TypeScript类型和接口代码,能省去不少时间。
这个也有现成的库:json-schema-to-typescript
所以说,思想高度决定了做事效率,这话说的一点没错哈~
写在后面
通过这篇文章,我们揭开前端编译神秘面纱,看到了前端常见的编译思想,发现它并非空洞无物的话术,也不是遥不可及的远方,而是一位隐藏在幕后的英雄,默默为我们的工作助力。就像那些看似无关紧要的小细节,却在关键时刻拯救整个项目。
好了,今天就到这里了,很长一段时间忙于工作学习,笔记和想法堆满草稿箱了。。。后续抽时间再整理,感兴趣的小伙伴们敬请期待哦~