前言
vue-loader 相信大家都很熟悉,其作为 webpack 众多加载器中一个为解析 .vue 文件的 loader ,主要的作用是将单文件组件SFC(single-file components 直译为:单个文件组件) 解析为 vue runtime是可识别的组件模块(js)。那么接下来我们来了解一下 vue-loader是如何进行转换的,在此之前我们先来一步一步来创建一个 webpack 项目,实现解析 .vue 模板文件。
注:本文不会详细介绍
webpack的使用,如果有不了解的,可以阅读webpack的官网文档
webpack 介绍
初始化项目
第一步:初始化 package.json
npm init -y
初始化项目成功后我们会发现webpack给我们创建了一个 package.json 文件
{
"name": "webpackloader",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
第二步:安装webpack打包工具
npm install webpack webpack-cli -D
-D是--save-dev的缩写webpackwebpack-cli通常是连在一起使用的install可以简写为i
第三步:初始化项目和文件目录,目录结构设计为
创建 webpack.config.js 文件,定义我们的打包规则
const path = require("path");
module.exports = {
mode: "development",
entry: './src/index.js',
output: {
filename: '[name].build.js',
path: path.resolve(__dirname, "dist"), // 打包后存放的目录
clean: true, // 构建前清除dist文件夹
}
};
现在后我们的文件目录如下
现在让我们使用控制台进入到项目目录,执行 npx webpack 打包尝试
npx webpack
运行后我们发现在我们的项目下有了一个 dist 文件夹,有一个 main.build.js 就是我们打包后的文件
打包携带模板渲染
上面的代码中,我们虽然使用
webpack打包了我们的代码,但是我们发现并没有把index.html打包进我们的dist目录,如果我们想使用打包后的js文件, 我们需要手动在dist添加index.html文件,并手动引用main.build.js这样会很麻烦
使用 html-webpack-plugin 自动打包写入
安装
npm i html-webpack-plugin -D
安装完成后修改 webpack.config.js 文件
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
entry: './src/index.js',
output: {
filename: '[name].build.js',
path: path.resolve(__dirname, "dist"), // 打包后存放的目录
clean: true, // 构建前清除dist文件夹
},
plugins: [
new HtmlWebpackPlugin()
]
};
重新执行 npx webpack 我们会发现,dist 文件夹下已经创建了 index.html 并自动引入了 main.build.js
输出 hello webpack
我们通过示例打包了我们的项目
dist, 但是我们现在运行会是一片空白,我们现在完善一下,让我们有一个点击按钮,点击后输出hello webpack
第一步: 修改 index.js
function getComponent() {
const btn = document.createElement("button");
btn.innerHTML = "click print";
btn.onclick = printFun;
return btn;
}
function printFun() {
console.log("hello webpack");
}
document.body.appendChild(getComponent());
重新打包后,我们运行页面,点击按钮后会输出
loader 的使用
在上面的示例中,我们使用
webpack打包了js文件,但是在真实的项目中我们的项目可不仅仅只有js文件,还会有:img、font、css等,那么为了使webpack能够打包这些文件,webpack提供了loader加载器来方便打包, 本文只介绍常用的加载器loader, 有兴趣的同学可以了解更多点击这里
使用 css loader 和 img loader
npm i style-loader css-loader -D
修改 webpack.config.js 使用 style-loader css-loader
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
entry: "./src/index.js",
output: {
filename: "[name].build.js",
path: path.resolve(__dirname, "dist"), // 打包后存放的目录
clean: true, // 构建前清除dist文件夹
},
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: "asset/resource",
},
],
},
plugins: [new HtmlWebpackPlugin()],
};
创建 global.css 文件 src/global.css
body {
background-color: rgb(11, 199, 199);
}
button {
width: 200px;
height: 50px;
}
.cdiv img {
width: 300px;
}
创建 img 目录并塞入一张图片 src/img
现在我们的目录结构是这样的
修改 index.js 调用 img/pyy.jpeg 和 global.css
import "./global.css";
import pyy from "./img/pyy.jpeg";
function getComponent() {
const element = document.createElement("div");
element.classList.add("cdiv");
const btn = document.createElement("button");
btn.innerHTML = "click print";
btn.onclick = printFun;
const icon = new Image();
icon.src = pyy;
element.append(icon, btn);
return element;
}
function printFun() {
console.log("hello webpack");
}
document.body.appendChild(getComponent());
重新执行 npx webpack 效果如下
现在我们已经能通过 webpack 能打包 css img js 了
webpack的loader有很多, 有兴趣的同学可以参考点击这里来学习使用,这里我不进行多的赘述,接下来我们了解和学习webpack是如何打包.vue文件的
准备工作
安装所需依赖
npm install vue@next -S
npm install vue-loader@next @vue/compiler-sfc
创建所需文件
创建 App.vue
<template>
<div>
<div>this is vue3</div>
<p>{{ title }}</p>
</div>
</template>
<script setup scope>
import { ref } from "vue";
const title = ref("vue-laoder");
</script>
<style>
.body {
background: red;
}
</style>
修改 webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader/dist/index");
module.exports = {
mode: "development",
entry: "./src/index.js",
output: {
filename: "[name].build.js",
path: path.resolve(__dirname, "dist"), // 打包后存放的目录
clean: true, // 构建前清除dist文件夹
},
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: "asset/resource",
},
{
test: /\.vue$/, // 所有 .vue 为后缀的文件全部会经过 vue-loader
use: ["vue-loader"],
},
],
},
plugins: [new VueLoaderPlugin(), new HtmlWebpackPlugin()],
};
修改 index.js
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
执行 npx webpack
我们会发现,页面上什么也没有,那是因为,vue 在渲染后,会将内容插入到一个id 为 app 的 root 标签中
修改 index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Webpack App</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body id="app"></body>
</html>
注意:我们在 webpack.config.js 中使用了 html-webpack-plugin 插件帮助我们自动生成 index.html 那么我们需要使用自己的 index.html ,那么应该怎么办?如果每次都在 dist 手动创建的话会很麻烦。 为了解决这个问题 html-webpack-plugin 给我们提供了api 我们可以直接使用它
修改 webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader/dist/index");
module.exports = {
mode: "development",
entry: "./src/index.js",
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
title: "Vue project is coming",
template: "./index.html", // 指定模板
}),
],
};
这时,我们的项目目录是这样
运行 npx webpack 会发现我们的 vue 组件数据已经被输出在页面上了
那么到这里
webpack的基本使用就介绍完了,当然webpack的功能远远不止这一些,后续我会出一个详细的文章来介绍webpack的使用规则
vue-loader 源码解析
读源码是一件非常有趣的事情,笔者也是在略读了一下
vue-loader源码后,觉得受益匪浅,决定写一篇相关文章记录一下学习的过程,希望能够帮助还未达到能够独立阅读源码的朋友们,话不多说我们开始吧
vue-loader 做的事情
当 webpack 在打包时,遇到 .vue 文件时,会命中VueLoaderPlugin,并调用vue-loader,也就是从这一刻开始 vue-loader 开始了工作,处理我们的 .vue 文件
我们看一下如下代码
const { VueLoaderPlugin } = require("vue-loader/dist/index");
module.exports = {
module: {
rules: [
{
test: /\.vue$/, // 所有 .vue 为后缀的文件全部会经过 vue-loader
use: ["vue-loader"],
},
],
},
plugins: [
new VueLoaderPlugin(),
],
};
在这段代码中,我们在 plugins 中使用了 VueLoaderPlugin,它的工作处理阶段主要分为两个部分,分别是 plugins 插件阶段和 loader 内容处理阶段
plugins插件阶段,在这一个阶段,其实就是new VueLoaderPlugin()的执行过程,VueLoaderPlugin的核心任务只有一个,那就是重组rules,包括将pitcher loader和已有规则clone处理后加入webpack配置信息module.rules中loader由于在plugin阶段动态注入了pitcher loader、clone rules,因此loader阶段主要包括如下 (先了解一下大致工作原理,后面的源码中会详细介绍此部分)
补充了解: 在
Webpack中,loader可以被分为 4 类:pre前置、post后置、normal普通和inline行内,四种loader调用先后顺序为:pre>normal>inline>post
源码阅读开始
本文只阅读
vue-loader的源码,也就是plugins插件阶段处理后的loader内容编译阶段
首先我们在 node_modules 中找到 vue-loader 文件夹,打开 dist/index.d.ts
我们在这里找到了
loader 方法,这个方法就是我们解析 .vue 文件的入口方法
loader
loader方法vue-loader的入口函数,这个函数接受两个参数,一个是webpack提供的loader上下文,主要用于获取webpack的一些参数(后面会有详细介绍),一个是source字符串,这个字符串就是我们要编译的.vue模板字符串
接着我们找到 loader 方法,位置在: dist/index.js
loader 方法接受了一个 source 参数,这个参数是 webpack 打包时传递给 vue-loader 的,他是一个字符串,具体的我们输出出来看一看
function loader(source) {
// source 就是我们的vue文件 输出后在控制台可见
console.log('source =====> ', source)
var _a;
}
我们输出 source 发现,source 其实就是我们的模板内容,只是在这里被处理成了字符串传递给了 loader(source)
loaderContext 上下文
// 定义了一个全局变量
var _a;
// vue-loader上下文(方法、版本号、开发环境等等)
const loaderContext = this;
loaderContext顾名思义就是loader提供的上下文工具,里面包含了loader的版本、方法、属性等,如果你想自定义一个loader那么你可以去详细的了解一下loaderContext
loaderContext 属性读取
// 检查是否安装了 `vue-loader` 插件
if (!errorEmitted &&
!loaderContext['thread-loader'] &&
!loaderContext[plugin_1.default.NS]) {
// 没安装的话 调用 emitError 方法 排除错误
loaderContext.emitError(new Error(`vue-loader was used without the corresponding plugin. ` +
`Make sure to include VueLoaderPlugin in your webpack config.`));
errorEmitted = true;
}
// loaderUtils webpack 提供的 lader工具类
// loaderUtils.stringifyRequest 将URL转为适合loader的相对路径
// console.log('loaderUtils ==> ', loaderUtils)
const stringifyRequest = (r) => loaderUtils.stringifyRequest(loaderContext, r);
// loaderContext loader上下文中获取配置
const { mode, target, sourceMap, rootContext, resourcePath, resourceQuery = '', } = loaderContext;
我们逐个输出 const {} = loaderContext 查看
现在我们获取到了输出的内容,大致内容如下
mode:production表示当前的开发环境target:web当前编译环境(web、app)sourceMap:true允许资源遍历rootContext:D:\\Pro\\leetCode\\webpack5项目的根目录resourcePath:D:\\Pro\\leetCode\\webpack5\\src\\App.vue当前编译的vue文件路径resourceQuery:?vue&type=template&id=7fc09a20根据文件参数生成的文件的地址 id 是唯一标识
resourceQuery
resourceQuery的作用是,根据引入文件路径参数的匹配路径type的不同,去触发不同的loader(type=template)vue-loader就是通过resourceQuery拼接不同的query参数,将各个标签分配给对应的loader进行处理- 例如上述的:
index.vue模板, 在触发vue-loader时会被转译为三个文件Script:"./index.vue?vue&type=script&lang=js&"Template: "./index.vue?vue&type=template&id=7fc09a20&scoped=true&"Style: "./index.vue?vue&type=style&index=0&id=7fc09a20&scoped=true&lang =css&"
注:如果暂时还没有明白 resourceQuery 的作用,没关系,接着向下读源码,等看完后再回来,相信你一定会茅塞顿开
loader执行阶段
首先来了解一下 loader(source) 执行时机*
a、
SFC文件调用vue-loader生成中间结果webpack module;
这一步将import x from './App.vue'处理成带后缀的import x from './App.vue?vue&type=template'
b、新生成的webpack module命中resourceQuery规则,调用pitcher loader,根据不同的处理逻辑,继续生成新的中间结果webpack module;
这一步将处理过的import x from './App.vue?vue&type=template'处理成具体的loader路径如:-!../../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=template&id=2964abc9&scoped=true&
c、再生成的新的webpack module命中了plugin阶段clone处理的具体的loader(我们前文介绍过,VueLoaderPlugin是首先执行的,它重组了rules规则,例如:template、style、script、img、font)等做处理。
如何区分前两步是否完成,在源码这种有一个判断,当a,b执行完毕后 incomingQuery.type会从 null 变成特定的类型
if (incomingQuery.type) { // 类型: template script style
console.log('c vue-loader incomingQuery.type => ', incomingQuery)
// selectBlock 根据 type分别处理不同的逻辑
return (0, select_1.selectBlock)(descriptor, id, options, loaderContext, incomingQuery, !!options.appendExtension);
}
console.log('ab vue-loader incomingQuery.type => ', incomingQuery)
我们分别输出一下处理前和处理后的结果
接下来我们来看看 vue-loader 是如何处理这些文件的
处理 template 核心代码
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}`;
// 获取最新的 form 'xxx' 地址
templateRequest = stringifyRequest(src + query);
templateImport = `import { ${renderFnName} } from ${templateRequest}`;
propsToAttach.push([renderFnName, renderFnName]);
}
处理 style 核心代码
// 处理vue文件中的 css
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;
}
stylesCode += (0, cssModules_1.genCSSModulesCode)(id, i, styleRequest, style.module, needsHotReload);
} else {
if (asCustomElement) {
stylesCode += `\nimport _style_${i} from ${styleRequest}`;
} else {
stylesCode += `\nimport ${styleRequest}`;
}
}
});
}
处理 js 核心代码
if (script || scriptSetup) {
// 判断是否是ts文件
const lang = (script === null || script === void 0 ? void 0 : script.lang) || (scriptSetup === null || scriptSetup === void 0 ? void 0 : scriptSetup.lang);
isTS = !!(lang && /tsx?/.test(lang));
// 判断是否是外联脚本
const src = (script && !scriptSetup && script.src) || resourcePath;
// 将属性拼接到一起 &setup=true&scope=true&lang=js
const attrsQuery = attrsToQuery((scriptSetup || script).attrs, 'js');
const query = `?vue&type=script${attrsQuery}${resourceQuery}`;
// 返回拼接后的文件路径 "./App.vue?vue&type=script&setup=true&scope=true&lang=js"
// 原先是: import app from './App.vue'
// 处理后是: import app from "./App.vue?vue&type=script&setup=true&scope=true&lang=js"
const scriptRequest = stringifyRequest(src + query);
scriptImport =
`import script from ${scriptRequest}\n` +
`export * from ${scriptRequest}`;
}
经过三轮分别处理,我们的index.vue文件被分为了三个模块,我们分别查看 vue-loader 处理后的路径是什么样子
第一轮处理完以后,新生成的 import x from './App.vue?vue&type=template' 会命中resourceQuery (resourceQuery我们上面输出了,他其实就是我们拼接的后缀) 规则,会调用 pitcher 进行进一步的处理
pitcher
上述的代码中,我们执行完了loader执行阶段的
a,vue-loader在执行完a后,会返回新的import x from 'x.vue?vue..'导入,在这个导入会触发pitcher(pitcher loader依据resourceQuery中是否带有?vue来触发的),在第二次导入中,我们携带了?vue所以触发了pitcher
dist/pitcher.js
patcher 主要做了两件事
- 命中
xx.vue?vue格式的路径 - 找出
template和style这两种模块,分别插入vue-loader自带的templateLoader和stylePostLoader这两种loader。
// 处理 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);
}
}
if (query.type === `custom` && shouldIgnoreCustomBlock(loaders)) {
return ``;
}
// 在这里处理 template
return genProxyModule(loaders, context, query.type !== 'template');
**pitcher loader**就是为了拦截这里生成的*vue模块*请求而生的。pitcher loader拦截了*vue模块*后,会找出*template*和*style*这两种模块,分别插入vue-loader自带的templateLoader和**stylePostLoader**这两种**loader**
注: 为什么没有拦截 script 模块,因为 js 文件是可以直接被解析的
我们输出一下经过patcher处理的 css import 路径
patcher 处理后的路径变成了模块真实的导入地址。
我们继续再来看这段代码
if (incomingQuery.type) { // 类型: template script style
// selectBlock 根据 type分别处理不同的逻辑
return (0, select_1.selectBlock)(descriptor, id, options, loaderContext, incomingQuery, !!options.appendExtension);
}
经过pitcher loader处理之后,模块解析又会找到Vue Loader源码里去再走一遍逻辑。但是这次会走入selectBlock这个模块进行处理。因为我们通过 vue-loader 和 pitcher 处理,incomingQuery.type现在是每个文件的具体的类型, 源码在vue-loader的select.js文件中
selectBlock 处理
在前两次处理中,我们拿到了带参数的
import的路径,那么在selectBlock中,我们会通过这些参数进行内容的编译(分别去触发不同的loader)
我们看一下 selectBlock 的源码逻辑
function selectBlock(descriptor, scopeId, options, loaderContext, query, appendExtension) {
// template
if (query.type === `template`) {
// if we are receiving a query with type it can only come from a *.vue file
// that contains that block, so the block is guaranteed to exist.
const template = descriptor.template;
if (appendExtension) {
loaderContext.resourcePath += '.' + (template.lang || 'html');
}
loaderContext.callback(null, template.content, template.map);
return;
}
// script
if (query.type === `script`) {
const script = (0, resolveScript_1.resolveScript)(descriptor, scopeId, options, loaderContext);
if (appendExtension) {
loaderContext.resourcePath += '.' + (script.lang || 'js');
}
loaderContext.callback(null, script.content, script.map);
return;
}
// styles
if (query.type === `style` && query.index != null) {
const style = descriptor.styles[Number(query.index)];
if (appendExtension) {
loaderContext.resourcePath += '.' + (style.lang || 'css');
}
loaderContext.callback(null, style.content, style.map);
return;
}
// custom
if (query.type === 'custom' && query.index != null) {
const block = descriptor.customBlocks[Number(query.index)];
loaderContext.callback(null, block.content, block.map);
}
}
可以看到,文件根据不同的template/script/style/custom模块对其相应的内容进行处理。最后使用webpack配置的loader对相应后缀进行规则匹配,并处理loaderContext.callback返回的相应内容。
至此 vue-loader 编译结束,其流程图如下
总结
vue-loader 打包步骤粗略地将一共就分为三步
第一步:webpack 调用 vue-loader 添加后缀导入
第二步:处理过的导入会触发 pitcher 进行loader解析,主要解析template 和 style
第三步:pitcher 处理过的导入会触发 selectBlock 进行各个模块的编译
webpack loader 可以被分为 4 类
pre 前置、post 后置、normal 普通和 inline 行内,四种loader调用先后顺序为:pre > normal > inline > post
plugin 阶段和 loader 分别做了什么
plugin阶段主要是new VueLoaderPlugin()的执行过程,他的主要任务是重组rules规则,并监听拦截.vue,使用 pitcher处理 vue-loader处理生成的新的导入路径, 转换为真实的loader路径
loader阶段主要分为三步
.vue文件调用loader生成新的vue module带参数导入路径- 新生成的带参导入路径,会命中
resourceQuery规则,调用pitcher loader,根据不同的处理逻辑,继续生成新的中间结果webpack modul - 新生成的结果会再次通过
vue-loader, 但是这一次会直接经过selectBlock, 并命中plugin阶段clone处理的具体loader,直接调用具体loader做处理。
参考文章
如阅读完仍有不理解的,可以参考一下两篇文章,非常的全面和详细
Webpack 案例 —— vue-loader 原理分析 - 字节前端的文章 - 知乎
奇葩说框架之SFC编译原理 - 转转技术团队的文章 - 知乎
最后
webpack是很强大很成熟的打包工具,我不敢保证我阅读的和理解的内容是否完全正确,如果有误请指出,不喜勿喷,感谢各位理解