Vue 文件解析、编译流程

5,184 阅读7分钟

本文将以目前(2020/10/26)最新的 vue-cli 版本 @vue/cli-service 4.5.8 (后文以 CLI4 代指)以脉络,详细分享 .vue 文件解析和编译的过程。解析指 .vue 文件被解析为 template|script|style 三份源码,编译指 template 源码被编译为渲染函数。

写在前面的一些说明:

  1. 本文并不涉及过多编译细节,主要目的是帮助大家熟悉编译流程,为解决问题提供编译方向上的思路。
  2. 本文使用 Vue 2.6.11 ,并不涉及 Vue3 相关内容。
  3. 阅读本文需要对 WebpackVue 有一定了解。

1. CLI4 配置处理规则

CLI4 生成的项目模板基于 Webpack ,我们都知道 Webpack 处理 .vue 文件是需要 loader 的,但 CLI4 封装很彻底,我们无法轻易在项目目录找到 Webpack 的配置文件,那么第一步就让我们找到 loader 吧。

1-1. package.json

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },

yarn build 执行的实际命令为 vue-cli-service build,显然 vue-cli-service 是 node_modules 中某一个包提供的命令。

1-2. node_modules/.bin/vue-cli-service

if [ -x "$basedir/node" ]; then
  "$basedir/node"  "$basedir/../@vue/cli-service/bin/vue-cli-service.js" "$@"
  ret=$?
else 
  node  "$basedir/../@vue/cli-service/bin/vue-cli-service.js" "$@"
  ret=$?
fi

node_modules 提供的所有命令都能在 node_modules/.bin 目录下找到,于是我们发现 vue-cli-service build 被进一步解释为 node node_modules/@vue/cli-service/bin/vue-cli-service.js build ,这已经是一个 node 能识别的指令了。

1-3. node_modules/@vue/cli-service/bin/vue-cli-service.js

// ...
const Service = require('../lib/Service')
// 创建 Service 实例
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())
// 获取命令携带参数
const rawArgv = process.argv.slice(2)
// ...
service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})
  1. 创建 Service 实例。
  2. 获取命令 vue-cli-service 携带的参数 build
  3. 调用 service.run 方法。

1-4. node_modules/@vue/cli-service/lib/Service.js

class Service {
  constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
	...
    // 成员变量 plugins 赋值
    this.plugins = this.resolvePlugins(plugins, useBuiltIn)
     ...
}

Service 的构造函数中,成员变量 plugins 调用 resolvePlugins 方法进行初始化。

resolvePlugins (inlinePlugins, useBuiltIn) {
  const idToPlugin = id => ({
    id: id.replace(/^.\//, 'built-in:'),
    apply: require(id)
  })

  let plugins

  // 预设的 plugins
  const builtInPlugins = [
    './commands/serve',
    './commands/build',
    './commands/inspect',
    './commands/help',
    // config plugins are order sensitive
    './config/base',
    './config/css',
    './config/prod',
    './config/app'
  ].map(idToPlugin)

  if (inlinePlugins) {
    plugins = useBuiltIn !== false
      ? builtInPlugins.concat(inlinePlugins)
      : inlinePlugins
  } else {
    const projectPlugins = Object.keys(this.pkg.devDependencies || {})
      .concat(Object.keys(this.pkg.dependencies || {}))
      .filter(isPlugin)
      .map(id => {
        if (
          this.pkg.optionalDependencies &&
          id in this.pkg.optionalDependencies
        ) {
          let apply = () => {}
          try {
            apply = require(id)
          } catch (e) {
            warn(`Optional dependency ${id} is not installed.`)
          }

          return { id, apply }
        } else {
          return idToPlugin(id)
        }
      })
    plugins = builtInPlugins.concat(projectPlugins)
  }

  // Local plugins
  if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
    const files = this.pkg.vuePlugins.service
    if (!Array.isArray(files)) {
      throw new Error(`Invalid type for option 'vuePlugins.service', expected 'array' but got ${typeof files}.`)
    }
    plugins = plugins.concat(files.map(file => ({
      id: `local:${file}`,
      apply: loadModule(`./${file}`, this.pkgContext)
    })))
  }

  return plugins
}

resolvePlugins 函数会把四个类型的插件整合为一个数组并返回:

  1. 初始化 Service 类时传入的 plugins
  2. CLI4 预设的 plugins
  3. devDependencies 中的 plugins
  4. vuePlugins 依赖中的 plugins

数组每个元素都存在一个 apply 方法用于加载相应 plugin 。

async run (name, args = {}, rawArgv = []) {
  // ...
  // load env variables, load user config, apply plugins
  this.init(mode)
  // ...
  // 从 commands 中取出 build 的处理函数(name = 'build')
  let command = this.commands[name]
  // ...
  const { fn } = command
  return fn(args, rawArgv)
}

run 方法调用 init 方法把 build 相应的处理函数挂到成员变量 commands 中,并从 commands 中取出 build 相应的处理函数,然后执行。

init (mode = process.env.VUE_CLI_MODE) {
  // ...
  // 加载用户 webpack 配置
  const userOptions = this.loadUserOptions()
  // 和默认配置合并
  this.projectOptions = defaultsDeep(userOptions, defaults())
  // ...
  // apply plugins.
  this.plugins.forEach(({ id, apply }) => {
    if (this.pluginsToSkip.has(id)) return
    apply(new PluginAPI(id, this), this.projectOptions)
  })
  // ...
}

init 加载用户配置,然后循环调用 plugins 的 apply 方法,传入配置作为参数。

这些插件主要有两个行为:

  1. 调用 PluginAPIregisterCommand 方法,把命令对应的模块(@vue/cli-service/lib/commands/*)挂到 Service 的成员变量 commands 中。
  2. 调用 PluginAPIchainWebpack 方法,把各自的 Webpack 链式配置(@vue/cli-service/config/*pushService 的成员变量 webpackChainFns 中。

下面来看看 build 命令要执行的逻辑。

1-5. node_modules/@vue/cli-service/lib/commands/build/index.js

(api, options) => {
  // 注册 build 命令
  api.registerCommand('build', {
    description: 'build for production',
    usage: 'vue-cli-service build [options] [entry|pattern]',
    options: {
      '--mode': `specify env mode (default: production)`,
      '--dest': `specify output directory (default: ${options.outputDir})`,
      '--modern': `build app targeting modern browsers with auto fallback`,
      '--no-unsafe-inline': `build app without introducing inline scripts`,
      '--target': `app | lib | wc | wc-async (default: ${defaults.target})`,
      '--inline-vue': 'include the Vue module in the final bundle of library or web component target',
      '--formats': `list of output formats for library builds (default: ${defaults.formats})`,
      '--name': `name for lib or web-component mode (default: "name" in package.json or entry filename)`,
      '--filename': `file name for output, only usable for 'lib' target (default: value of --name)`,
      '--no-clean': `do not remove the dist directory before building the project`,
      '--report': `generate report.html to help analyze bundle content`,
      '--report-json': 'generate report.json to help analyze bundle content',
      '--skip-plugins': `comma-separated list of plugin names to skip for this run`,
      '--watch': `watch for changes`,
      '--stdin': `close when stdin ends`
    }
  }, async (args, rawArgs) => {
    // 执行命令的回调
    // 把默认参数合并到 args
    for (const key in defaults) {
      if (args[key] == null) {
        args[key] = defaults[key]
      }
    }
    ...
    await build(args, api, options)
  }

调用 PluginAPI.registerCommand 方法注册 build 命令和回调,在回调中会向 args 中添加一些默认选项(比如 target: 'app'),然后执行该文件下的 build 方法。

async function build (args, api, options) {
  ...
  // resolve raw webpack config
  let webpackConfig
  if (args.target === 'lib') {
    webpackConfig = require('./resolveLibConfig')(api, args, options)
  } else if (
    args.target === 'wc' ||
    args.target === 'wc-async'
  ) {
    webpackConfig = require('./resolveWcConfig')(api, args, options)
  } else {
    webpackConfig = require('./resolveAppConfig')(api, args, options)
  }

  ...
  return new Promise((resolve, reject) => {
    webpack(webpackConfig, (err, stats) => {
      ...
    })
  })
}

build 方法根据 args.target 的值匹配配置文件,并在使用 Webpack Nodejs Api 执行打包。上面我们刚提到 target 的默认值是 app ,所以默认加载 ./resolveAppConfig.js 这个配置文件,然后调用 webpack 执行打包。

1-6. node_modules/@vue/cli-service/lib/commands/build/resolveAppConfig.js

module.exports = (api, args, options) => {
  ...
  const config = api.resolveChainableWebpackConfig()
  ...
  return api.resolveWebpackConfig(config)
})

该文件调用 PluginAPIresolveChainableWebpackConfig 方法获得 Webpack 的链式配置,并在返回前调用 PluginAPIresolveWebpackConfig 方法把链式配置转换为 JSON 配置,接下来我们看看这两个方法的具体实现。

1-7. node_modules/@vue/cli-service/lib/PluginAPI.js

 resolveWebpackConfig (chainableConfig) {
    return this.service.resolveWebpackConfig(chainableConfig)
 }

  resolveChainableWebpackConfig () {
    return this.service.resolveChainableWebpackConfig()
  }

PluginAPI 中的两个获取配置的方法,其实都调用的 Service 中的同名方法。

1-8. node_modules/@vue/cli-service/lib/Service.js

  resolveChainableWebpackConfig () {
    const chainableConfig = new Config()
    // apply chains
    this.webpackChainFns.forEach(fn => fn(chainableConfig))
    return chainableConfig
  }

在 1-4 我们提到,webpackChainFns 储存着 node_modules/@vue/cli-service/lib/config/ 目录下的链式配置,resolveChainableWebpackConfig 函数则构造了一个 Webpack Config 对象,并使用该对象执行链式配置,其中就包括 node_modules/@vue/cli-service/lib/config/base.js 中的关于处理 .vue 文件的配置:

webpackConfig.module
  .rule('vue')
    .test(/\.vue$/)
    .use('cache-loader')
      .loader(require.resolve('cache-loader'))
      .options(vueLoaderCacheConfig)
      .end()
    .use('vue-loader')
      .loader(require.resolve('vue-loader'))
      .options(Object.assign({
        compilerOptions: {
          whitespace: 'condense'
        }
      }, vueLoaderCacheConfig))

webpackConfig
  .plugin('vue-loader')
    .use(require('vue-loader').VueLoaderPlugin)

原来 CLI4 也是使用的是 vue-loader 来处理 .vue 文件,只不过相较于 CLI3 还依赖一个 VueLoaderPlugin 的插件(歪嘴一笑:我早知道了)。

 resolveWebpackConfig (chainableConfig = this.resolveChainableWebpackConfig()) {
    ...
    let config = chainableConfig.toConfig()
    ...
    return config
 }

resolveWebpackConfig 方法则比较简单,直接调用 toConfig 并返回。

2. VueLoaderPlugin 重写规则

上文提到 CLI4 相较于 CLI3 会额外依赖 VueLoaderPlugin 的插件,并且该插件在 1-8 的流程中进行了初始化,所以让我们先来看看这个插件会做些什么。

2-1. node_modules/vue-loader/lib/plugin.js

if (webpack.version && webpack.version[0] > 4) {
  // webpack5 and upper
  VueLoaderPlugin = require('./plugin-webpack5')
} else {
  // webpack4 and lower
  VueLoaderPlugin = require('./plugin-webpack4')
}

根据 Webpack 版本匹配插件版本 ,本文使用 Webpack 4.44.2

2-2. node_modules/vue-loader/lib/plugin-webpack4.js

class VueLoaderPlugin {
  apply (compiler) {
    // ...
    const vueLoaderUse = vueUse[vueLoaderUseIndex]
    vueLoaderUse.ident = 'vue-loader-options'
    vueLoaderUse.options = vueLoaderUse.options || {}
    
    // create a cloned rule
    const clonedRules = rules
      .filter(r => r !== vueRule)
      .map(cloneRule)
    
    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
      }
    }
    
    // replace original rules
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}

Webpack 插件在初始化时会执行插件(函数)原型链上的 apply 方法,而 VueLoaderPlugin.apply 方法重写了当前实例的 loaders 的配置。

  1. 处理 .vue 文件的 loader 配置被分离出来,存放在变量 vueLoaderUse 中。
  2. compiler.options.module.rules 中的其余规则复制到变量 clonedRules 中。
  3. 基于 vueLoaderUse 中用户设置的 options 生成一个新的规则 pitcher
  4. 重写 compiler.options.module.rules

重写后的 rules 存在两条和 Vue 相关的规则:

  1. vue-loader/lib/loaders/pitcher.js (本条是新增的)。
  2. Webpack 原始配置中的 vue-loader 和 cache-loader 。

可以描述为:

{
  test: /\.vue$/,
  use: [
    'vue-loader/lib/loaders/pitcher.js',
  ]
},
{
  test: /\.vue$/,
  use: [
    'vue-loader/lib/index.js',
    'cache-loader/dist/cjs.js'
  ]
}

3. 解析 Vue 文件

上面已经理清了处理文件的 loaders ,下面就跟随这些 loaders 来看具体阅读下解析和编译的过程。

3-1. node_modules/vue-loader/lib/index.js

const { parse } = require('@vue/component-compiler-utils')
...
module.exports = function (source) {
  ...
  const {
    target,
    request,
    minimize,
    sourceMap,
    rootContext,
    resourcePath,
    resourceQuery
  } = loaderContext
  const rawQuery = resourceQuery.slice(1)
  // 获取 loader 传参
  const incomingQuery = qs.parse(rawQuery)
  ...
  const descriptor = parse({
    source,
    // 默认使用用户配置的 compiler
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })
  
  // 如果 loader 配置时有指定 type 存在
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }
  
  ...
  // template
  let templateImport = `var render, staticRenderFns`
  let templateRequest
  if (descriptor.template) {
    const src = descriptor.template.src || resourcePath
    const idQuery = `&id=${id}`
    const scopedQuery = hasScoped ? `&scoped=true` : ``
    const attrsQuery = attrsToQuery(descriptor.template.attrs)
    const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}`
    const request = templateRequest = stringifyRequest(src + query)
    templateImport = `import { render, staticRenderFns } from ${request}`
  }
  
  ...
  let code = `
    ${templateImport}
    ${scriptImport}
    ${stylesCode}
    ...
  `
  ...
  code += `\nexport default component.exports`
  return code
}

可以看到 vue-loader 的职责主要是三件:

  1. 调用 @vue/component-compiler-utilsparse 函数
  2. 如果存在 loader 的参数存在 type 属性,则执行 selectBlock 函数,用于选取源码(比如从 Vue 文件中选取 template 标签中的源码,依赖与上方 parse 函数的解析结果)
  3. 根据 parse 返回结果凭借字符串,并返回

parse 函数存在一个 compiler 参数,默认获取用户配置的编译器,如果未配置则通过 loadTemplateCompiler 加载一个默认编译器。

function loadTemplateCompiler (loaderContext) {
  try {
    return require('vue-template-compiler')
  } catch (e) {
    if (/version mismatch/.test(e.toString())) {
      loaderContext.emitError(e)
    } else {
      loaderContext.emitError(new Error(
        `[vue-loader] vue-template-compiler must be installed as a peer dependency, ` +
        `or a compatible compiler implementation must be passed via options.`
      ))
    }
  }
}

loadTemplateCompiler 加载 vue-template-compiler 库,并在加载错误时给出一定提示。

parse 返回的结果是一个对象,它记录了三个特殊标签 template|script|style 的内容在 Vue 文件中的位置,以便后续 loader 可以通过位置信息选取正确的内容。

3-2. node_modules/@vue/component-compiler-utils/dist/parse.js

function parse(options) {
    const { source, filename = '', compiler, compilerParseOptions = { pad: 'line' }, sourceRoot = '', needMap = true } = options;
    const cacheKey = hash(filename + source + JSON.stringify(compilerParseOptions));
    let output = cache.get(cacheKey);
    if (output)
        return output;
    output = compiler.parseComponent(source, compilerParseOptions);
    if (needMap) {
        if (output.script && !output.script.src) {
            output.script.map = generateSourceMap(filename, source, output.script.content, sourceRoot, compilerParseOptions.pad);
        }
        if (output.styles) {
            output.styles.forEach(style => {
                if (!style.src) {
                    style.map = generateSourceMap(filename, source, style.content, sourceRoot, compilerParseOptions.pad);
                }
            });
        }
    }
    cache.set(cacheKey, output);
    return output;
}

parse 会先检查缓存,如果存在则返回缓存内容,如果没有缓存则:

  1. 执行 VueTemplateCompiler 的 parseComponent 函数,获取解析结果。
  2. 执行 sourceMap 相关处理。

3-3. node_modules/vue-template-compiler/build.js

function parseComponent (
  content,
  options
) {
  if ( options === void 0 ) options = {};
  var sfc = {
    template: null,
    script: null,
    styles: [],
    customBlocks: [],
    errors: []
  };
  var depth = 0;
  var currentBlock = null;
  
  var warn = function(msg) {
    sfc.errors.push(msg);
  }
  
  function start(...args) {
    // 处理开始标签,保存标签 block 对象
  }
  
  function end(tag, start) {
    // 处理结束标签,修改标签 block 对象
  }
  
  function checkAttrs (block, attrs){
    for (var i = 0; i < attrs.length; i++) {
      var attr = attrs[i];
      if (attr.name === 'lang') {
        block.lang = attr.value;
      }
      if (attr.name === 'scoped') {
        block.scoped = true;
      }
      if (attr.name === 'module') {
        block.module = attr.value || true;
      }
      if (attr.name === 'src') {
        block.src = attr.value;
      }
    }
  }
  
  function padContent(block, pad) {
    // 填充空行,保证分离出的 template、script 代码块行号不变(便于 sourceMap 映射)
  }
  
  parseHTML(content, {
    warn: warn,
    start: start,
    end: end,
    outputSourceRange: options.outputSourceRange
  });

  return sfc
}

parseComponent 接收两个参数,第一个参数 content 是 Vue 文件的源码代,第二个参数 options 默认为 { pad: 'line' } ,是可以由用户配置的解析选项。该函数会创建一个 sfc 对象,用于存放对 Vue 文件的解析结果,它的结构描述如下:

interface SFCDescriptor {
  filename: string
  source: string
  template: SFCBlock
  script: SFCBlock
  scriptSetup: SFCBlock
  styles: SFCBlock[]
  customBlocks: SFCBlock[]
}

interface SFCBlock {
  type: 'template' | 'script' | 'style'
  attrs: { lang: string, functional: boolean },
  content: string, // 内容,等于 html.slice(start, end)
  start: number, // 开始偏移量
  end: number, // 结束偏移量
  lang: string
}

除此之外还声明了 warnstartend 三个函数,并当做 parseHTML 的参数传入,所以接下来我们进入 parseHTML 一窥究竟。

    function parseHTML (html, options) {
      var stack = [];
      var expectHTML = options.expectHTML;
      var isUnaryTag$$1 = options.isUnaryTag || no;
      var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
      var index = 0;
      var last, lastTag;
      while (html) {
        last = html;
        // Make sure we're not in a plaintext content element like script/style
        if (!lastTag || !isPlainTextElement(lastTag)) {
          var textEnd = html.indexOf('<');
          if (textEnd === 0) {
            // Comment:
            if (comment.test(html)) {
              var commentEnd = html.indexOf('-->');

              if (commentEnd >= 0) {
                if (options.shouldKeepComment) {
                  options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3);
                }
                advance(commentEnd + 3);
                continue
              }
            }

            // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
            if (conditionalComment.test(html)) {
              var conditionalEnd = html.indexOf(']>');

              if (conditionalEnd >= 0) {
                advance(conditionalEnd + 2);
                continue
              }
            }

            // 处理 Doctype:
            ...
            // 处理 End tag:
            ...
            // 处理 Start tag:
            var startTagMatch = parseStartTag();
            if (startTagMatch) {
              handleStartTag(startTagMatch);
              if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
                advance(1);
              }
              continue
            }
          }
          ...
        } else {
          ...
          parseEndTag(stackedTag, index - endTagLength, index);
        }

      // Clean up any remaining tags
      parseEndTag();

      function advance (n) {
        index += n;
        html = html.substring(n);
      }

      function parseStartTag () {
        ...
      }

      function handleStartTag (match) {
        ...
      }

      function parseEndTag (tagName, start, end) {
        ...
      }
    }

parseHTML 的主要职责是从 Vue 文件中分离出 template|script|style 这三个标签内的代码,方式就是匹配开始和结束标签位置,并把这些信息通过传入的 start|end 函数记录到 parseComponent 中的 sfc 变量中(当然还会记录标签上的属性 lang|scoped|module|src)。

这其中的关键的函数 advance ,它的作用是改变偏移量 index 和从 html 中删除已经处理过的代码( html 是 Vue 文件的内容)。

详细步骤见时序图: 大纲

parseHtml 执行完毕后,Vue 文件的所有信息就都记录在了 parseComponent 的对象 sfc 中,并一步步把结果返回到 3-1 ,3-1 获取到 sfc 对象后会使用其中信息进行字符串拼接,最终生成一个新的模块文件(代码)交给下一个 loader 处理。

此时,我们已经将完全实现了对 Vue 文件的解析! 本节还最后两小节内容,展示获取到的模板源码,是如何调用 VueTemplateCompiler 进行编译的(这属于 Vue 源码范畴)。

解析前后文件内容变化:

app.vue

<template>
  <div id="app">
    <h1>Vue Compile Share</h1>
    <Details></Details>
  </div>
</template>

<script lang="ts">
import Vue from "vue";
import Details from "./components/details.vue";

export default Vue.extend({
  name: "app",
  components: {
    Details
  }
});
</script>

<style lang="stylus">
#app {
  font-family Avenir, Helvetica, Arial, sans-serif
  -webkit-font-smoothing antialiased
  -moz-osx-font-smoothing grayscale
  text-align center
  color red
  margin-top 60px
}
</style>

编译后的中间代码

import { render, staticRenderFns } from "./App.vue?vue&type=template&id=7a0f6a6c&"
import script from "./App.vue?vue&type=script&lang=ts&"
export * from "./App.vue?vue&type=script&lang=ts&"
import style0 from "./App.vue?vue&type=style&index=0&lang=stylus&"

/* normalize component */
import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  null,
  null
)
export default component.exports

可以看到,这时的导入是存在 type 类型的,所以会被上文提到的 selectBlock 处理,但这里只是生成了一个字符串,它会通过什么杨的方式再走一次 VueLoader 呢?我们接着往下看。

3-4 node_modules/vue-loader/lib/loaders/pitcher.js

module.exports.pitch = function (remainingRequest) {
  ...
  // 处理 type 为 tempalte 的情况
  if (query.type === `template`) {
    const path = require('path')
    const cacheLoader = cacheDirectory && cacheIdentifier
      ? [`${require.resolve('cache-loader')}?${JSON.stringify({
        // For some reason, webpack fails to generate consistent hash if we
        // use absolute paths here, even though the path is only used in a
        // comment. For now we have to ensure cacheDirectory is a relative path.
        cacheDirectory: (path.isAbsolute(cacheDirectory)
          ? path.relative(process.cwd(), cacheDirectory)
          : cacheDirectory).replace(/\\/g, '/'),
        cacheIdentifier: hash(cacheIdentifier) + '-vue-loader-template'
      })}`]
      : []

    const preLoaders = loaders.filter(isPreLoader)
    const postLoaders = loaders.filter(isPostLoader)

    const request = genRequest([
      ...cacheLoader,
      ...postLoaders,
      templateLoaderPath + `??vue-loader-options`,
      ...preLoaders
    ])
    // console.log(request)
    // the template compiler uses esm exports
    return `export * from ${request}`
  }
  ...
}

上文我们能说了处理 Vue 的规则还有一条,即使用 vue-loader/lib/loaders/pitcher.js 最后,上文经过 parse 解析后存在的 import { render, staticRenderFns } from "./App.vue?vue&type=template&id=7a0f6a6c&" 就会进入到这里的流程中,并最终被替换为使用 vue-loader/lib/index.jsvue-loader/lib/loaders/templateLoader.js 来处理。

所以 Vue 文件再一次被 VueLoader 处理 ,这次依然会经过 parse 解析,但我们上文提到过 parse 是存在缓存机制的,所以第二次会直接命中缓存并返回第一次解析的结果,然后判断存在 type,所以就会执行 selectBlock 方法并返回 template|script|style 的源代码。

3-5. node_modules/vue-loader/lib/select.js

module.exports = function selectBlock (
  descriptor,
  loaderContext,
  query,
  appendExtension
) {
  // template
  if (query.type === `template`) {
    if (appendExtension) {
      loaderContext.resourcePath += '.' + (descriptor.template.lang || 'html')
    }
    loaderContext.callback(
      null,
      descriptor.template.content,
      descriptor.template.map
    )
    return
  }

  // script
  ...
  // styles
  ...
  // custom
  ...
}

就可以可以看到从 Vue 文件中分离出来的 template/script/style 代码,通过 loaderContext.callback 传给了下一个 loader 处理,即 vue-loader/lib/loaders/templateLoader.js

3-6. node_modules/vue-loader/lib/loaders/templateLoader.js

module.exports = function (source) {
  // ...
  // allow using custom compiler via options
  const compiler = options.compiler || require('vue-template-compiler')

  const compilerOptions = Object.assign({
    outputSourceRange: true
  }, options.compilerOptions, {
    scopeId: query.scoped ? `data-v-${id}` : null,
    comments: query.comments
  })
  
  // for vue-component-compiler
  const finalOptions = {
    source,
    filename: this.resourcePath,
    compiler,
    compilerOptions,
    // allow customizing behavior of vue-template-es2015-compiler
    transpileOptions: options.transpileOptions,
    transformAssetUrls: options.transformAssetUrls || true,
    isProduction,
    isFunctional,
    optimizeSSR: isServer && options.optimizeSSR !== false,
    prettify: options.prettify
  }

  const compiled = compileTemplate(finalOptions)
  
  // ...
  const { code } = compiled

  // finish with ESM exports
  return code + `\nexport { render, staticRenderFns }`
}

可以看到,该 loader 的功能主要是生成一个编译需要的配置对象,然后把这个配置对象传给 @vue/component-compiler-utils 库中的 compileTemplate 函数,并在获取到编译结果后稍作修改便返回。

3-7. node_modules/@vue/component-compiler-utils/dist/compileTemplate.js

function compileTemplate(options) {
    const { preprocessLang } = options;
    const preprocessor = preprocessLang && consolidate[preprocessLang];
    if (preprocessor) {
        return actuallyCompile(Object.assign({}, options, {
            source: preprocess(options, preprocessor)
        }));
    }
    else if (preprocessLang) {
        // 提醒特定语言进行预处理
    }
    else {
        return actuallyCompile(options);
    }
}

检查是否存在预处理语言:

  1. 如果存在且有预处理器,则先进行预处理,再进行编译。
  2. 存在但没有预处理器,则报错提示。
  3. 如果不存在,则执行编译。
const assetUrl_1 = __importDefault(require("./templateCompilerModules/assetUrl"));
const srcset_1 = __importDefault(require("./templateCompilerModules/srcset"));

function actuallyCompile(options) {
    const { source, compiler, compilerOptions = {}, transpileOptions = {}, transformAssetUrls, transformAssetUrlsOptions, isProduction = process.env.NODE_ENV === 'production', isFunctional = false, optimizeSSR = false, prettify = true } = options;
    const compile = optimizeSSR && compiler.ssrCompile ? compiler.ssrCompile : compiler.compile;
    let finalCompilerOptions = compilerOptions;
    if (transformAssetUrls) {
        const builtInModules = [
            transformAssetUrls === true
                ? assetUrl_1.default(undefined, transformAssetUrlsOptions)
                : assetUrl_1.default(transformAssetUrls, transformAssetUrlsOptions),
            srcset_1.default(transformAssetUrlsOptions)
        ];
        finalCompilerOptions = Object.assign({}, compilerOptions, {
            modules: [...builtInModules, ...(compilerOptions.modules || [])],
            filename: options.filename
        });
    }
    const { ast, render, staticRenderFns, tips, errors } = compile(source, finalCompilerOptions);
    
    // ...
    return {
      ast,
      code,
      source,
      tips,
      errors
   };
}

actuallyCompile 这个函数中,会把两个特殊的处理规则合并入 finalCompilerOptions 对象中,他们分别是用来处理资源路径的 assetUrl_1 和设置响应式图片的 srcset_1,至于它们会对这些属性值做哪些调整,我们之后再讲。

得到新的编译配置项后,就会调用在配置中的 compiler.compile 函数,并返回结果。如果你足够仔细,就会发现 compiler 是在 3-2 templateLoader.js 中被添加编译配置项的,它在用户未进行明确指定编译器时默认使用 vue-template-compiler (const compiler = options.compiler || require('vue-template-compiler') )。

4. VueTemplateCompiler 编译模板

分析待定 ...