
我们上回书说道沙箱编译的vue编译部分,很多jym以为我会就此金盆洗手, 等着东家发完盒饭踏实回家搬砖。
甚至有Jy 略带嘲讽的给我评论道:

我能从他们的字里行间体会到他们在质问我,就这?我那啥都那啥了你就给我看这?
而由于行文是从丘处机路过牛家村开始,略显墨迹,阅读量,点赞量,可谓惨不忍睹。
发生这种情况,我以为有三个原因
亲爱的jym啊,我怎么会让自己晚节不保呢?我怎么能让自己这么没有深度呢?
当然还有后续啊,今天我们就来讲讲原理,毕竟原理才是技术圈的流量密码
本着帮人帮到底 送佛送到西的优良品质,也本着绝不认输,不点赞不断更的态度(主要是一个点赞都没有脸上实在挂不住了)。
我们今天就来细致的讲一下vue模板 在到底是如何编译的。
也能让大家能理解,vue项目的整个编译流程,这样就能在工作中更好的学以致用,这样也能在面试官的面前游刃有余
废话少说,我们正式开始!
当然国际惯例,讲编译原理之前,我们还是要从丘处机路过牛家村开始
在介绍正常的vue模板编译流程,我们需要一些前置支持,我们知道的代码编译分为两种
sfc单文件组件编译html 的编译其实就非常简单了说白了就是利用全量vue 版本,拿到html的字符串进行编译即可
举个例子:
<head>
<script src="./vue.global.js"></script>
</head>
<div id="app">
<div>
{{message}}
</div>
</div>
<script>
const app = Vue.createApp({
setup() {
const { ref } = Vue
const message = ref('hello world')
return {
message
}
}
})
app.mount('#app')
</script>Ï
<body>
</body>以上代码他最后就会在vue.global.js的加持下解析 id为app的字符串模板

这也是vue能够在行业内屹立不倒的原因,小而美,上手简单,开箱即用。
而反观react,相信干过的都知道,你想要使用他的语法,光引入一个js 文件那是远远不够的!
而他的实现原理也非常简单,仅仅在初始化的时候将模板内容拿到,然后调用 中的@vue/compiler 执行编译即可!
由于我们这期编译原理为主,运行时我们暂时按下不表
我们来看源代码:
import { compile } from '@vue/compiler-dom'
// 初始化编译函数
function compileToFunction(
template: string | HTMLElement,
options?: CompilerOptions
): RenderFunction {
// 判断了是否是字符串,因为在初始化的时候,可以使用字符
if (!isString(template)) {
if (template.nodeType) {
template = template.innerHTML
} else {
__DEV__ && warn(`invalid template option: `, template)
return NOOP
}
}
const key = template
// 作者还机制的使用了缓存,如果已经编译过了,就直接返回
const cached = compileCache[key]
if (cached) {
return cached
}
// 开始根据id拿到模板
if (template[0] === '#') {
const el = document.querySelector(template)
if (__DEV__ && !el) {
warn(`Template element not found or is empty: ${template}`)
}
// 此处已经拿到模板了
template = el ? el.innerHTML : ``
}
// 拿到编译后的代码
const { code } = compile(template)
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
// 生成render 函数
return (compileCache[key] = render)
}以上vue3的源码中,我们就能清楚的看出来,是在初始化的时候,引用模板编译模块,来生成render 函数。
这个render函数相信大家都不陌生,毕竟面试常考,我也就不再赘述
接下来,就是sfc单文件组件编译
sfc单文件组件编译上期我们说过sfc单文件组件,他从本质上来说,就是只适用于vue的一种规范,既然,是适用于vue规范,那么必然不行业公认的,于是他就需要转义,给他变成浏览器能跑起来的代码

而编译sfc单文件组件,就需要node环境,因为node 能做文件的io操作!
使用上其实很简单,我们利用node读取vue单文件组件,然后将其中内容,分开编译输出,打包为浏览器可以运行的代码!
然而,在这个前端纷繁复杂,生态繁荣的年代!我们干事情千万不要从0开始,我们要从1到10,我们要站在巨人的肩膀上!
众所周知,在前端基建领域的巨人,非webpack莫属!
他就能实现我们要做的所有事情,我们只需要付出少量心血写个插件即可!
于是Vue Loader诞生了!
Vue Loader 在上回书,我们也说道过, 是一个 webpack 的 loader,它允许你以一种名为单文件组件 (SFCs)的格式撰写 Vue 组件
简而言之,Vue Loader 在webpack的基础上建立了灵活且极其强大的前端工作流,来帮助撰你写 Vue.js 应用。
他的使用方式非常简单,在webpack中配置即可
// webpack.config.js
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
module: {
rules: [
// ... 其它规则
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
},
plugins: [
// 这个插件使 .vue 中的各类语言块匹配相应的规则
new VueLoaderPlugin()
]
}而在开箱即用的vue-cli中直接内置了,我么你甚至都不需要引用!下载相关脚手架即可开始开发!
那webpack 中的Vue Loader插件到底做了什么事情呢?

一图胜千言,但还是简单的说一下吧!
.vue文件的处理分为那么几步关键点:parse方法 生成 descriptor描述文件,描述符中包含了vue解析后的各个结果,比如template、style、scripttype 区别并缓存内容提高编译性能compiler-sfc生成 code代码
如上图所示,我们可以简单的看下他编译后的代码!
那么接下下来我们来探究一下vue-loader的原理了,细致的探究一下他是怎么实现的。
// 默认导出的loader函数注意loader本质上就是个函数
export default function loader(
this: webpack.loader.LoaderContext,// webpack的loader上下文
source: string// 源码
) {
const loaderContext = this
//拿到上下文中的相关内容
const {
mode,
target,
sourceMap,
rootContext,
resourcePath,
resourceQuery: _resourceQuery = '',
} = loaderContext
//一些前置内容的处理,比如loaderUtils获取配置对象,传入参数处理等,不是我们本次关心的重点
const rawQuery = _resourceQuery.slice(1)
const incomingQuery = qs.parse(rawQuery)
const resourceQuery = rawQuery ? `&${rawQuery}` : ''
const options = (loaderUtils.getOptions(loaderContext) ||
{}) as VueLoaderOptions
const isServer = options.isServerBuild ?? target === 'node'
const isProduction =
mode === 'production' || process.env.NODE_ENV === 'production'
const filename = resourcePath.replace(/\?.*$/, '')
// 通过vue/compiler-sfc 分离内容
const { descriptor, errors } = parse(source, {
filename,
sourceMap,
})
const asCustomElement =
typeof options.customElement === 'boolean'
? options.customElement
: (options.customElement || /\.ce\.vue$/).test(filename)
// 缓存当前编译内容,防止下次编译
setDescriptor(filename, descriptor)
// 作用域CSS和热重载的处理,生成唯一id
const rawShortFilePath = path
.relative(rootContext || process.cwd(), filename)
.replace(/^(\.\.[\/\\])+/, '')
const shortFilePath = rawShortFilePath.replace(/\\/g, '/')
const id = hash(
isProduction
? shortFilePath + '\n' + source.replace(/\r\n/g, '\n')
: shortFilePath
)
//vue-loader 推导策略
// 这里主要就是通过vue插件来处理编译分离后的内容
// 主要就是生成引用的js、render函数,css等内容
//比如'?vue&type=script&lang=js' 就会走js 的处理逻辑
// 分别通过插件styleInlineLoader,stylePostLoader。templateLoader 来处理
if (incomingQuery.type) {
return selectBlock(
descriptor,
id,
options,
loaderContext,
incomingQuery,
!!options.appendExtension
)
}
// 前置处理css scoped
const hasScoped = descriptor.styles.some((s) => s.scoped)
const needsHotReload =
!isServer &&
!isProduction &&
!!(descriptor.script || descriptor.scriptSetup || descriptor.template) &&
options.hotReload !== false
const propsToAttach: [string, string][] = []
// 处理script
let scriptImport = `const script = {}`
let isTS = false
const { script, scriptSetup } = descriptor
if (script || scriptSetup) {
const lang = script?.lang || scriptSetup?.lang
isTS = !!(lang && /tsx?/.test(lang))
const src = (script && !scriptSetup && script.src) || resourcePath
const attrsQuery = attrsToQuery((scriptSetup || script)!.attrs, 'js')
//拼接下次请求的query
const query = `?vue&type=script${attrsQuery}${resourceQuery}`
const scriptRequest = stringifyRequest(src + query)
// 生成代码
scriptImport =
`import script from ${scriptRequest}\n` +
// support named exports
`export * from ${scriptRequest}`
}
// 处理模板template
let templateImport = ``
let templateRequest
const renderFnName = isServer ? `ssrRender` : `render`
const useInlineTemplate = canInlineTemplate(descriptor, isProduction)
if (descriptor.template && !useInlineTemplate) {
const src = descriptor.template.src || resourcePath
const idQuery = `&id=${id}`
const scopedQuery = hasScoped ? `&scoped=true` : ``
const attrsQuery = attrsToQuery(descriptor.template.attrs)
const tsQuery =
options.enableTsInTemplate !== false && isTS ? `&ts=true` : ``
// 同样的处理模板内容
const query = `?vue&type=template${idQuery}${scopedQuery}${tsQuery}${attrsQuery}${resourceQuery}`
templateRequest = stringifyRequest(src + query)
// 生成代码
templateImport = `import { ${renderFnName} } from ${templateRequest}`
propsToAttach.push([renderFnName, renderFnName])
}
// 处理styles内容
let stylesCode = ``
let hasCSSModules = false
const nonWhitespaceRE = /\S+/
if (descriptor.styles.length) {
descriptor.styles
.filter((style) => style.src || nonWhitespaceRE.test(style.content))
.forEach((style, i) => {
const src = style.src || resourcePath
const attrsQuery = attrsToQuery(style.attrs, 'css')
const idQuery = !style.src || style.scoped ? `&id=${id}` : ``
const inlineQuery = asCustomElement ? `&inline` : ``
const query = `?vue&type=style&index=${i}${idQuery}${inlineQuery}${attrsQuery}${resourceQuery}`
const styleRequest = stringifyRequest(src + query)
if (style.module) {
if (asCustomElement) {
loaderContext.emitError(
`<style module> is not supported in custom element mode.`
)
}
if (!hasCSSModules) {
stylesCode += `\nconst cssModules = {}`
propsToAttach.push([`__cssModules`, `cssModules`])
hasCSSModules = true
}
// 如果有热更新,拼接添加css 代码 添加热更新等内容
stylesCode += genCSSModulesCode(
id,
i,
styleRequest,
style.module,
needsHotReload
)
} else {
// 否则直接拼接
if (asCustomElement) {
stylesCode += `\nimport _style_${i} from ${styleRequest}`
} else {
stylesCode += `\nimport ${styleRequest}`
}
}
// TODO SSR critical CSS collection
})
}
let code = [templateImport, scriptImport, stylesCode]
.filter(Boolean)
.join('\n')
// attach scope Id for runtime use
if (hasScoped) {
propsToAttach.push([`__scopeId`, `"data-v-${id}"`])
}
// 拼接处最后的代码段
if (!propsToAttach.length) {
code += `\n\nconst __exports__ = script;`
} else {
code += `\n\nimport exportComponent from ${exportHelperPath}`
code += `\nconst __exports__ = /*#__PURE__*/exportComponent(script, [${propsToAttach
.map(([key, val]) => `['${key}',${val}]`)
.join(',')}])`
}
//生成代码最终返回
code += `\n\nexport default __exports__`
return code
}他的步骤其实本质上其实就是在开发环境下来拼接生成esmodule代码, 然后代码就会拼接成如下这样:

当然这只是第一步,因为你发现他又引入了单独拆分后的文件。 接下来,我们就要对每个单独拆分后的类型文件做处理,此时的处理就要依赖于vue-loader这个包中的一个webpack插件来做下一步。
VueLoaderPlugin,他的源代码非常简单,主要就是兼容了webpack4和webpack5
import webpack = require('webpack')
declare class VueLoaderPlugin implements webpack.Plugin {
static NS: string
apply(compiler: webpack.Compiler): void
}
let Plugin: typeof VueLoaderPlugin
// 兼容webpack4和webpack5
if (webpack.version && webpack.version[0] > '4') {
Plugin = require('./pluginWebpack5').default
} else {
Plugin = require('./pluginWebpack4').default
}
export default Plugin接下来,我们就以webpack5为例讲讲这个插件怎么处理剩余的内容。
至于为啥将webpack5,就跟买东西一样啊,买新不加旧!Ï众所周知,webpack插件本质上是个class类,
那我们只需要看看这个类里面干了什么事情即可
我们之前说了 pluginWebpack5本质上是个类,这个类由于能拿到webpack编译的参数,于是,他便可以动态的改变他的配置对象,从而注入新的loader来实现拆分后文件的解析,这也是我们引入插件后就能解析内容的原理
代码如下:
class VueLoaderPlugin implements Plugin {
static NS = NS
apply(compiler: Compiler) {
//拿到编译之后的一些模块
const normalModule = compiler.webpack.NormalModule || NormalModule
//相当于做一些出错误处理,日志输出啥的
compiler.hooks.compilation.tap(id, (compilation) => {
normalModule
.getCompilationHooks(compilation)
.loader.tap(id, (loaderContext: any) => {
loaderContext[NS] = true
})
})
// 此处省略无关紧要的一些代码
//...
//...
// 开始注册编译模板loader
const templateCompilerRule = {
loader: require.resolve('./templateLoader'),
resourceQuery: (query?: string) => {
if (!query) {
return false
}
const parsed = qs.parse(query.slice(1))
return parsed.vue != null && parsed.type === 'template'
},
options: vueLoaderOptions,
}
//pitcher注册除了模板之外的剩余内容
const pitcher = {
loader: require.resolve('./pitcher'),
resourceQuery: (query?: string) => {
if (!query) {
return false
}
// 解析 query 上带有 vue 标识的资源
const parsed = qs.parse(query.slice(1))
return parsed.vue != null
},
}
// 重写loader规则以便能够解析vue文件剩余内容
compiler.options.module!.rules = [
pitcher,
templateCompilerRule,
...rules,
]
}
}以上简写代码中,我们能很清楚的看到,他重写了rules 也就是之前那个webpack的配置表
接下来就水到渠成了,由于vue-loader返回了拼接后的文件,那么他就会去处理拼接后的文件,也就是我们前面那张截图
然后就会根据正则规则触发那两个新的loader 从而实现编译
接下来我们也来简单介绍一下这两个loader
// 模板的处理其实就是调用vue/compiler-sfc的compileTemplate方法
const TemplateLoader: webpack.loader.Loader = function (source, inMap) {
source = String(source)
const loaderContext = this
// 前置处理
const options = (loaderUtils.getOptions(loaderContext) ||
{}) as VueLoaderOptions
const isServer = options.isServerBuild ?? loaderContext.target === 'node'
const isProd =
loaderContext.mode === 'production' || process.env.NODE_ENV === 'production'
const query = qs.parse(loaderContext.resourceQuery.slice(1))
const scopeId = query.id as string
const descriptor = getDescriptor(loaderContext.resourcePath)
const script = resolveScript(
descriptor,
query.id as string,
options,
loaderContext
)
let templateCompiler: TemplateCompiler | undefined
if (typeof options.compiler === 'string') {
templateCompiler = require(options.compiler)
} else {
templateCompiler = options.compiler
}
// 主要就是这里,调用vue/compiler-sfc的compileTemplate方法
const compiled = compileTemplate({
source,
filename: loaderContext.resourcePath,
inMap,
id: scopeId,
scoped: !!query.scoped,
slotted: descriptor.slotted,
isProd,
ssr: isServer,
ssrCssVars: descriptor.cssVars,
compiler: templateCompiler,
compilerOptions: {
...options.compilerOptions,
scopeId: query.scoped ? `data-v-${scopeId}` : undefined,
bindingMetadata: script ? script.bindings : undefined,
...resolveTemplateTSOptions(descriptor, options),
},
transformAssetUrls: options.transformAssetUrls || true,
})
// tips
if (compiled.tips.length) {
compiled.tips.forEach((tip) => {
loaderContext.emitWarning(tip)
})
}
// 返回结果,让下一个loader处理
const { code, map } = compiled
loaderContext.callback(null, code, map)
}而编译后的结果在babel和sourceMap的加持下变成了这样

我们可以很清楚的看到render函数
pitcher本质上就是处理除了模板以外的情况
// 处理css 内容,js 内容可以用babel 处理
const stylePostLoaderPath = require.resolve('./stylePostLoader')
const styleInlineLoaderPath = require.resolve('./styleInlineLoader')
// pitcher-loader 是个空壳子
const pitcher: webpack.loader.Loader = (code) => code
// pitcher这个loader 中的 pitch 方法才是真正的pitcher
//loader 总是从右到左被调用。有些情况下,loader 只关心 request 后面的 元数据(metadata),
//并且忽略前一个 loader 的结果。在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。
export const pitch = function () {
const context = this as webpack.loader.LoaderContext
const rawLoaders = context.loaders.filter(isNotPitcher)
let loaders = rawLoaders
if (loaders.some(isNullLoader)) {
return
}
// 接受参数
const query = qs.parse(context.resourceQuery.slice(1))
// 省略无用代码
//......
// 处理css 内容
if (query.type === `style`) {
const cssLoaderIndex = loaders.findIndex(isCSSLoader)
if (cssLoaderIndex > -1) {
const afterLoaders =
query.inline != null
? [styleInlineLoaderPath]
: loaders.slice(0, cssLoaderIndex + 1)
const beforeLoaders = loaders.slice(cssLoaderIndex + 1)
return genProxyModule(
[...afterLoaders, stylePostLoaderPath, ...beforeLoaders],
context,
!!query.module || query.inline != null
)
}
}
// 处理其他情况,最后将生成代码再次放到下一个loader 中处理
return genProxyModule(loaders, context, query.type !== 'template')
}到这我们就能很清楚的理解他loader对于整个vue文件的解析了。
总体上来说,他就是解析了内容之后,生成目标代码,再通过,别的loader 去解析处理,最终形成浏览器可以使用的代码
好了,说到这,我们整个vue模板在node端,和weback 的加持下,算是解析完成了。
接下来就轮到我们的沙箱了
在我们的浏览器中,由于没有io操作,以及webpack的加持,我们将这笨重的webpack移植到浏览器上,略显费劲。
于是在大佬们的不断探索下,他们换了个思路,我们可以在浏览器端实现一个类似loader的东西,来转换代码不就行了吗。
在node vue-loader不就是干这个用的吗?
在开始之前我们可以从结果以及目的,来倒推过程和写法!
我们知道,我们的目的,就是将一个vue模板代码 变成浏览器可执行代码然后通过eval 来执行?
那我们构造一个可以通过eval执行的函数不就可以了吗

以上代码其实就是我们要构建的结果,只不过和node环境不同的是,我们需要生成一个函数来整体执行。
这样一来,我们就能确定我们生成代码需要什么基础设施了babel、@vue/compiler-sfc、scss预处理器即可
这样一来他的原理就呼之欲出了,我们只需要有相应的实现然后执行即可。
上回书只说到了vue3的编译内容,如有兴趣传送门在此
这一回,我们雨露均沾
我们先说怎么编译vue模板
说起编译vue模板 我们还是仿照上一部分的步骤
loader处理loader处理这一块其实很简单,我们只需要拿到模板code 代码然后调用loader处理即可!
// 有个函数相当于rules 匹配文件名,模仿webpack配置
mapTransformers(module: Module): Array<[string, any]> {
// 碰见js文件,就用babel转换
if (/^(?!\/node_modules\/).*\.(((m|c)?jsx?)|tsx)$/.test(module.filepath)) {
return [
[
'babel-transformer',
{
presets: ['solid'],
plugins: ['solid-refresh/babel'],
},
],
];
}
if (/\.css$/.test(module.filepath)) {
return [
['css-transformer', {}],
['style-transformer', {}],
];
}
//碰见vue文件,就用vue3-loader
if (/\.vue$/.test(module.filepath)) {
return [
['vue3-transformer', {}],
];
}
//碰见图片,就用url-loader
if (/\.(png|jpeg|svg)$/.test(module.filepath)) {
return [
['url-transformer', {}],
];
}
throw new Error(`No transformer for ${module.filepath}`);
}这一步我们在上回书说道,如有兴趣传送门在此,我们不再赘述!
由于 compiler-sfc处理之后,是esmodule内容,所以我们还需要用在浏览器端的babel做一层转换
代码如下:
import * as babel from '@babel/standalone';
//使用babel 编译代码
export async function babelTransform({ code, filepath, config }: ITransformData): Promise<any> {
const requires: Set<string> = new Set();
const presets = await getPresets(config?.presets ?? []);
const plugins = await getPlugins(config?.plugins ?? []);
plugins.push(collectDependencies(requires));
// 传入一些配置,进行babel 转义
const transformed = babel.transform(code, {
filename: filepath,
presets,
plugins,
ast: false,
sourceMaps: 'inline',
compact: /node_modules/.test(filepath),
});
if (!transformed.code) {
transformed.code = 'module.exports = {};';
}
// 返回编译结果,并且拿到依赖包名字
return Promise.resolve({
code: transformed.code,
dependencies: requires,
})
}注意这里的babel是浏览器端专用的,大家可以去babel官网自行翻阅!
scss的代码处理,自不用多说,在上回书也说道了,只需要用sass.js这个包即可
拿到编译后的代码之后,我们就可以执行代码了!
export default function (
code: string,
require: Function,
context: { id: string; exports: any; hot?: any },
env: Object = {},
globals: Object = {}
) {
const global = g;
const process = {
env: {
NODE_ENV: 'development',
},
}; // buildProcess(env);
// @ts-ignore
g.global = global;
// 构建函数中使用的变量!
// 这里需要注意的是,我们之所以需要构建变量,是为了模拟nodejs中的require等方法,
//因为babel
//所以我们需要在浏览器端模拟这些方法,来使程序跑起来
const allGlobals: { [key: string]: any } = {
require,
module: context,
exports: context.exports,
process,
global,
swcHelpers,
...globals,
};
if (hasGlobalDeclaration.test(code)) {
delete allGlobals.global;
}
const allGlobalKeys = Object.keys(allGlobals);
const globalsCode = allGlobalKeys.length ? allGlobalKeys.join(', ') : '';
const globalsValues = allGlobalKeys.map((k) => allGlobals[k]);
try {
// 构建函数
const newCode = `(function $csb$eval(` + globalsCode + `) {` + code + `\n})`;
// 执行函数
(0, eval)(newCode).apply(allGlobals.global, globalsValues);
return context.exports;
} catch (err) {
logger.error(err);
logger.error(code);
let error = err;
if (typeof err === 'string') {
error = new Error(err);
}
// @ts-ignore
error.isEvalError = true;
throw error;
}
}ok,到这里,我们就算是基本的讲了一个在线IDE的沙箱编译的基本原理流程,当然,整个项目要想跑起来,需要的知识点还有很多,篇幅有限,我们今天先到这里!
后续如果还有下回书,我们继续讲react的编译,以及怎样内置依赖包等能力!
请JYM支持,让咱们还有下一回,今天这一回咱们打完收工,领盒饭去了!