vue-loader 的执行原理

如果一个框架(如 weex 和快应用)需要支持 vue dsl,那么就离不开 vue-loader。

vue-loader 负责将 vue 文件转为单文件组件 (SFCs)对象,并调用 vue-template-compiler 将模板编译成 render 函数。

本文主要分析其执行原理。

首先还是贴一下官方文档:Vue Loader

下面主要分为这两个部分说明

  • 构建示例项目
  • 分析 vue-loader 的执行

初始化项目

根据文档说明先快速构建一个项目,并安装上依赖

安装依赖

新建项目

npm init

安装 vue-loader、vue-template-compiler,编译 vue 文件

npm install -D vue-loader vue-template-compiler

安装 webpack 4 和 webpack-cli,打包静态资源

npm install -D webpack@4 webpack-cli

安装 css-loader 和 babel-loader,编译 css 和模拟对 js 的处理

npm install -D css-loader babel-loader

然后在根目录下新建 webpack.config.jssrc/index.vue 文件。

最终目录应该是这样的
├─package-lock.json
├─package.json
├─webpack.config.js
├─src
| └index.vue
└dist

添加单组件文件的内容

往 index.vue 中添加如下内容,也可以是其他任意的组件内容

<template>
  <!-- template里只能有一个根节点 -->
  <div class="demo-page">
    <text class="title">欢迎打开{{ title }}</text>
  </div>
</template>

<script>
  export default {
    data: function () {
      return {
        title: "vue-loader示例",
      };
    },
  };
</script>

<style>
  .demo-page {
    flex-direction: column;
    justify-content: center;
    align-items: center;
    width: 100%;
  }
</style>

添加 webpack 配置

往 webpack.config.js 中添加如下配置,其中 vue-style-loader 是跟随 vue-loader 安装的,不用自行再安装。

const path = require("path");
const VueLoaderPlugin = require("vue-loader/lib/plugin");

module.exports = {
  mode: "development",
  entry: "./src/index.vue",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: "vue-loader",
      },
      // 它会应用到普通的 `.js` 文件
      // 以及 `.vue` 文件中的 `<script>` 块
      {
        test: /\.js$/,
        loader: "babel-loader",
      },
      // 它会应用到普通的 `.css` 文件
      // 以及 `.vue` 文件中的 `<style>` 块
      {
        test: /\.css$/,
        use: ["vue-style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    // 请确保引入这个插件来施展魔法
    new VueLoaderPlugin(),
  ],
};

添加脚本

往 package.json 中添加脚本 "build": "webpack"

{
  ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  ...
}

打包

最后执行 npm run build 即可正常打包,在 dist/main.js 看到输出的文件。

后面的调试方法参考该文章:使用 vscode 的调试功能

分析执行

plugin 会在 webpack 启动后不久就开始执行,所以我们先从 VueLoaderPlugin 处开始看起。

VueLoaderPlugin 的执行

本文基于 webpack 4 编写,对应的 VueLoaderPlugin 定义在 vue-loader/lib/plugin-webpack4.js 中,在 apply 方法处打断点,并开始调试。

VueLoaderPlugin 的执行主要就是处理 loader 中的 rules。VueLoaderPlugin 的作用是将定义过的其它规则复制并应用到 .vue 文件里相应语言的块(js、style)。在 webpack 启动时会立即执行。

首先看一下第一部分的代码,可以看到就是在 webpack 配置中想尽办法找到和 .vue 或者 .vue.html 匹配的 rules,获取到后存储在 vueRule

class VueLoaderPlugin {
  apply (compiler) {
.
  ...
    // use webpack's RuleSet utility to normalize user rules
    const rawRules = compiler.options.module.rules
    const { rules } = new RuleSet(rawRules)
    // find the rule that applies to vue files
    let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`))
    if (vueRuleIndex < 0) {
      vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`))
    }
    const vueRule = rules[vueRuleIndex]
    ...
}

然后就是克隆一份所有除了 vue rules 以外的 rules,此处也暂不展开说明

const clonedRules = rules.filter((r) => r !== vueRule).map(cloneRule);

然后会准备一个 pitcher-loader,该 loader 只要资源文件路径的?后有 vue 就会执行(如 xxx/filename.vue?vue&xxxx)。

const pitcher = {
  loader: require.resolve("./loaders/pitcher"),
  resourceQuery: (query) => {
    const parsed = qs.parse(query.slice(1));
    return parsed.vue != null;
  },
  options: {
    cacheDirectory: vueLoaderUse.options.cacheDirectory,
    cacheIdentifier: vueLoaderUse.options.cacheIdentifier,
  },
};

让我们先简单看一下 pitcher 文件,通过代码就可以知道,pitcher 只是为了负责拦截所有 vue 块请求,并将其转化为适当的请求。module.exports 并没有其他逻辑实现。

module.exports = code => code
// This pitching loader is responsible for intercepting all vue block requests
// and transform it into appropriate requests.
module.exports.pitch = function (remainingRequest) {
  ...
}

最后就是修改 rules,重写 rules 为 pitcher-loader,克隆的 loader(clonedRules)和原本定义的 loader(rules)

// replace original rules
compiler.options.module.rules = [pitcher, ...clonedRules, ...rules];

根据 rules 可以知道默认的执行顺序:
pitch

  • pitcher-loader 中 pitch,对有 vue 参数的请求进行转化

loader

  • 用户自己定义的 rules
  • 经过克隆的用户定义的 rules(没有 vue-loader)
  • pitcher-loader(无用)

loader 第一轮执行

根据 webpack 的配置,从下到上开始第一轮的 vue 文件处理。

此处不贴完整代码,建议打开vue-loader github 仓库对照阅读。

首先 vue-loader 会检测是否在 webpack config 中正确配置了 VueLoaderPlugin

check-plugin

然后 vue 文件会迎来他的第一次转换,即下图中的 parse 方法,该方法实际调用的是 vue-template-compiler/build.js 中的 parseComponent 方法

parse

该方法将 vue 文件的内容转换成了 SFC 描述符对象,即文件内容(字符串)转换成下面这个对象,将每个部分(script、styles、template)明确分了开来。

parse-result

接着会有一个 incomingQuery.type 的判断,现在为 false 暂时忽略。

最后的解析结果:

import {
  render,
  staticRenderFns,
} from "./index.vue?vue&type=template&id=2964abc9&";
import script from "./index.vue?vue&type=script&lang=js&";
export * from "./index.vue?vue&type=script&lang=js&";
import style0 from "./index.vue?vue&type=style&index=0&lang=css&";
/* normalize component */
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js";
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  null,
  null
);
/* hot reload 代码省略 */
component.options.__file = "src/index.vue";
export default component.exports;

可以看到经过第一轮的解析,vue 文件被拆分为了几个请求,主要为三个:

  • ./index.vue?vue&type=template
  • ./index.vue?vue&type=script
  • ./index.vue?vue&type=style

loader 第二轮执行

根据上面第一轮 vue-loader 的解析结果,开始第二轮的解析。

import {
  render,
  staticRenderFns,
} from "./index.vue?vue&type=template&id=2964abc9&";

因为请求中有 vue,故该请求会给 VueLoaderPlugin 定义的 pitcher 拦截,重新分发请求。让我们直接看一下 pitch 重写后的请求。

export * from "-!../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&";

可以看到该请求已经转换成这 2 个 loader 的事情了

  • vue-loader
  • templateLoader

直接看 vue-loader,关键是这块刚才没有提的逻辑

block

此处的 selectBlock 方法会根据 url 参数中的 type 获取对应模块的内容(template 等)给下一个 loader。

最后就是 templateLoader,该 loader 会会拿到 template 字符串。vue-loader 就是通过这个 loader 调用 vue-template-compiler 对模板进行编译,得到最终的 render 函数。

这是最后编译获得的结果:

var render = function () {
  var _vm = this;
  var _h = _vm.$createElement;
  var _c = _vm._self._c || _h;
  return _c("div", { staticClass: "demo-page" }, [
    _c("text", { staticClass: "title" }, [
      _vm._v("欢迎打开" + _vm._s(_vm.title)),
    ]),
  ]);
};
var staticRenderFns = [];
render._withStripped = true;

export { render, staticRenderFns };