webpack5 vue-loader 打包源码解析

931 阅读12分钟

前言

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
  1. -D 是 --save-dev 的缩写
  2. webpack webpack-cli 通常是连在一起使用的
  3. install 可以简写为 i

第三步:初始化项目和文件目录,目录结构设计为

image.png

创建 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文件夹
  }
};

现在后我们的文件目录如下

image.png

现在让我们使用控制台进入到项目目录,执行 npx webpack 打包尝试

npx webpack

运行后我们发现在我们的项目下有了一个 dist 文件夹,有一个 main.build.js 就是我们打包后的文件

image.png

打包携带模板渲染

上面的代码中,我们虽然使用 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());

重新打包后,我们运行页面,点击按钮后会输出

image.png

loader 的使用

在上面的示例中,我们使用 webpack 打包了 js 文件,但是在真实的项目中我们的项目可不仅仅只有 js 文件,还会有:img、font、css 等,那么为了使 webpack 能够打包这些文件, webpack 提供了 loader 加载器来方便打包, 本文只介绍常用的加载器 loader, 有兴趣的同学可以了解更多点击这里

使用 css loaderimg 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

现在我们的目录结构是这样的

image.png

修改 index.js 调用 img/pyy.jpegglobal.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 效果如下

image.png

现在我们已经能通过 webpack 能打包 css img js

webpackloader 有很多, 有兴趣的同学可以参考点击这里来学习使用,这里我不进行多的赘述,接下来我们了解和学习 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

image.png

我们会发现,页面上什么也没有,那是因为,vue 在渲染后,会将内容插入到一个idapproot 标签中

修改 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",   // 指定模板
    }),
  ],
};

这时,我们的项目目录是这样

image.png

运行 npx webpack 会发现我们的 vue 组件数据已经被输出在页面上了

image.png

那么到这里 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 内容处理阶段

  1. plugins 插件阶段,在这一个阶段,其实就是 new VueLoaderPlugin() 的执行过程,VueLoaderPlugin 的核心任务只有一个,那就是重组 rules,包括将pitcher loader和已有规则clone处理后加入webpack配置信息module.rules
  2. loader由于在plugin阶段动态注入了pitcher loaderclone 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

image.png 我们在这里找到了 loader 方法,这个方法就是我们解析 .vue 文件的入口方法

loader

loader 方法 vue-loader 的入口函数,这个函数接受两个参数,一个是 webpack 提供的 loader 上下文,主要用于获取 webpack 的一些参数(后面会有详细介绍),一个是 source 字符串,这个字符串就是我们要编译的 .vue 模板字符串

接着我们找到 loader 方法,位置在: dist/index.js

image.png

loader 方法接受了一个 source 参数,这个参数是 webpack 打包时传递给 vue-loader 的,他是一个字符串,具体的我们输出出来看一看

function loader(source) {
    // source 就是我们的vue文件 输出后在控制台可见
    console.log('source =====> ', source)
    var _a;
}

我们输出 source 发现,source 其实就是我们的模板内容,只是在这里被处理成了字符串传递给了 loader(source)

image.png

loaderContext 上下文

    // 定义了一个全局变量 
    var _a;
    // vue-loader上下文(方法、版本号、开发环境等等)
    const loaderContext = this;

loaderContext 顾名思义就是 loader 提供的上下文工具,里面包含了 loader 的版本、方法、属性等,如果你想自定义一个 loader 那么你可以去详细的了解一下 loaderContext

image.png

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 查看

image.png

现在我们获取到了输出的内容,大致内容如下

  • 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的不同,去触发不同的 loadertype=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)

我们分别输出一下处理前和处理后的结果

image.png

接下来我们来看看 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 处理后的路径是什么样子

image.png

第一轮处理完以后,新生成的 import x from './App.vue?vue&type=template' 会命中resourceQuery (resourceQuery我们上面输出了,他其实就是我们拼接的后缀) 规则,会调用 pitcher 进行进一步的处理

pitcher

上述的代码中,我们执行完了loader执行阶段avue-loader 在执行完 a 后,会返回新的 import x from 'x.vue?vue..'导入,在这个导入会触发 pitcherpitcher loader依据resourceQuery中是否带有 ?vue来触发的),在第二次导入中,我们携带了?vue 所以触发了 pitcher

dist/pitcher.js

patcher 主要做了两件事

  1. 命中 xx.vue?vue 格式的路径
  2. 找出templatestyle这两种模块,分别插入vue-loader自带的templateLoaderstylePostLoader这两种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 路径

image.png

image.png

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 编译结束,其流程图如下

image.png

总结

vue-loader 打包步骤粗略地将一共就分为三步

第一步:webpack 调用 vue-loader 添加后缀导入
第二步:处理过的导入会触发 pitcher 进行loader解析,主要解析templatestyle
第三步: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阶段主要分为三步

  1. .vue文件调用loader生成新的vue module 带参数导入路径
  2. 新生成的带参导入路径,会命中resourceQuery规则,调用pitcher loader,根据不同的处理逻辑,继续生成新的中间结果webpack modul
  3. 新生成的结果会再次通过vue-loader, 但是这一次会直接经过 selectBlock, 并命中 plugin阶段clone处理的具体loader,直接调用具体loader做处理。

参考文章

如阅读完仍有不理解的,可以参考一下两篇文章,非常的全面和详细

Webpack 案例 —— vue-loader 原理分析 - 字节前端的文章 - 知乎

奇葩说框架之SFC编译原理 - 转转技术团队的文章 - 知乎

最后

webpack是很强大很成熟的打包工具,我不敢保证我阅读的和理解的内容是否完全正确,如果有误请指出,不喜勿喷,感谢各位理解