我们上回书说道沙箱编译
的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
支持,让咱们还有下一回,今天这一回咱们打完收工,领盒饭去了!