vue-loader原理流程真~~解析!看完我又学“废”了

2,806 阅读15分钟

其实这篇内容就是一起学习交流一下vue-loader主流程的一些逻辑和处理方式,也希望能在检索资料时,大家能看到更多一些不同的内容或知识点,在梳理后也能沉淀下来,减少阅读源码学习的成本。本篇部分内容也是基于自己理解的观点,可能比较主观~~
长文警告⚠️详细解析 长文警告⚠️详细解析

vue-loader它能做什么

要说loader,那先提一下webpack,毕竟loader是webpack生态中的一环。有了loader,我们才能在基于JavaScript的程序打包整个工程,而且还能处理不是js后缀的文件,例如css、scss等。而我们学习探讨的重点vue-loader就是

  • 它可以处理我们写的VUE单文件组件。
  • template标签、style标签、script标签这些还能基于webpack的配置能被其他loader处理,也就是你vue文件里的scss还是能被post-css loader和scss loader,file-loader处理的。
  • 甚至我们可以扩展自己的模块标签。
  • 经过webpack和loader的操作,vue文件能被拆解成js和css两个部分,能被浏览器理解。
  • ...

更多功能了解和配置相关内容,一定要看看官方文档👇
Vue Loader 是什么?

我想弄明白这几个疑惑

实话说,看了这些文档和对它表面的理解,我自己的学习思路和求知可能有以下几点

  1. loader怎么解析vue文件的,它在webpack中的工作流程大概是啥?这是我最想了解的~~
  2. 为什么vue-loader要配合VueLoaderPlugin插件一起使用?
  3. 它怎么处理template标签内的类html语法,转化成render函数?
  4. 它怎么处理css或其他扩展语言的?顺便了解一下scoped属性是怎么作用在当前组件的。
  5. 最后也有一个比较大的疑问,为什么script标签和css标签内的内容,还能被别的loader处理,那些babel-loader等不是只匹配处理js文件或css文件吗?
  6. 我还能做些啥?感觉挺牛b的啦,对我具体工作应该也能有些帮助吧。

代码解析

我们先看看,使用vue-loader时,wepack配置长什么样。vue-loader必须配合VueLoaderPlugin插件使用,所以我们以这两个文件为入口,试着理解看看都做了什么。

// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  module: {
    rules: [
      // ... 其它规则
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    // 请确保引入这个插件!
    new VueLoaderPlugin()
  ]
}

规则中的test,指的是在代码中匹配到引入.vue文件的依赖时就会经过vue-loader处理。

两个问题去学习解析源码

  • 直接看插件和loader入口文件做了啥。
  • 都了解做了啥后,我们分析一下一个vue文件是经过什么流程处理,变成可执行的js内容的。

直接看完下面的解析查阅文档后,其实我写的时候还是挺蒙的,所以后面一定要尝试总结的梳理流程。可以直接看看后面的流程梳理图,再回头看解析,效果会快一些。

VueLoaderPlugin

插件的能力就是处理一些,loader本身不负责的内容(loader应该更注重处理代码、文件内容)。比如在某个钩子周期,修改webpack配置呀,输出一些东西,启动某些服务,发送请求等等。
看一下vueloader插件做了些啥。
VueLoaderPlugin入口文件:

...
class VueLoaderPlugin {
  apply (compiler) {
    // add NS marker so that the loader can detect and report missing plugin
    if (compiler.hooks) {
      // webpack 4
      compiler.hooks.compilation.tap(id, compilation => {
        const normalModuleLoader = compilation.hooks.normalModuleLoader
        normalModuleLoader.tap(id, loaderContext => {
          // 普通模块 loader,真正(一个接一个地)加载模块图(graph)中所有模块的函数。
					// 设置标记
          loaderContext[NS] = true
        })
      })
    } else {
      // webpack < 4
      ...
    }

    // use webpack's RuleSet utility to normalize user rules
    // 获取用户webpack配置中的rules
    const rawRules = compiler.options.module.rules
    const { rules } = new RuleSet(rawRules)

    // find the rule that applies to vue files
    // 获取到vue相关配置在rules中的index
    let vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue`))
    if (vueRuleIndex < 0) {
      vueRuleIndex = rawRules.findIndex(createMatcher(`foo.vue.html`))
    }
    const vueRule = rules[vueRuleIndex]

    ...

    // get the normlized "use" for vue files
    const vueUse = vueRule.use
    // get vue-loader options
    const vueLoaderUseIndex = vueUse.findIndex(u => {
      return /^vue-loader|(\/|\\|@)vue-loader/.test(u.loader)
    })

    ...

    // make sure vue-loader options has a known ident so that we can share
    // options by reference in the template-loader by using a ref query like
    // template-loader??vue-loader-options
    const vueLoaderUse = vueUse[vueLoaderUseIndex]
    vueLoaderUse.ident = 'vue-loader-options'
    vueLoaderUse.options = vueLoaderUse.options || {}

    // for each user rule (expect the vue rule), create a cloned rule
    // that targets the corresponding language blocks in *.vue files.
    // 复制并处理那些不属于vue相关的loader
    const clonedRules = rules
      .filter(r => r !== vueRule)
      .map(cloneRule)

    // global pitcher (responsible for injecting template compiler loader & CSS
    // post loader)
    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
    // 更新webpack中rules的数组
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]
  }
}

function cloneRule (rule) {
  const { resource, resourceQuery } = rule
  // Assuming `test` and `resourceQuery` tests are executed in series and
  // synchronously (which is true based on RuleSet's implementation), we can
  // save the current resource being matched from `test` so that we can access
  // it in `resourceQuery`. This ensures when we use the normalized rule's
  // resource check, include/exclude are matched correctly.
  ...
	// 复制rule,并对resourceQuery进行处理,只匹配特定的参数请求路径
  return res
}

VueLoaderPlugin.NS = NS
module.exports = VueLoaderPlugin

其实工业级的代码中还是有很多环境判断,异常处理。我们就跳过看具体的主逻辑流程是什么就好了。

  1. 在compilation钩子中处理标记,使得vue-loader知道配合使用的插件初始化了。
  2. 在webpack配置中找到vue相关的loader配置,然后复制除了vue相关的loader配置外的rule。clonedRules
  3. clonedRules中每个rule的resourceQuery都有特别的处理。
  4. 返回新的rules = [ pitcher(一个loader), ...clonedRules(复制的), ...rules(原始的)]

比较陌生或无法直观理解的就是,为什么有clonedRules和pitcher。接下来看看这两个是什么情况。

clonedRules

复制出来的rules长什么样,又有什么作用呢?规则是为了匹配某种路径,然后执行规则内的loader,看看它复制出一堆要干啥。
举个例子,下面是一个url-loader复制后的rule

{
    "resource": {
    	"test": function
    },
    "resourceQuery":function,
    "use": [
        {
            "options": {
                "limit": 4096,
                "fallback": {
                    "loader": "/Users/.../node_modules/_file-loader@4.3.0@file-loader/dist/cjs.js",
                    "options": {
                        "name": "img/[name].[hash:8].[ext]"
                    }
                }
            },
            "ident": "ref--1-0",
            "loader": "/Users/.../node_modules/_url-loader@2.3.0@url-loader/dist/cjs.js"
        }
    ]
}

复制rule的目的就是增加这两个字段resource,resourceQuery(看上面代码)。其他的内容还是一样的。看看克隆方法中生成的这两函数都做了什么匹配策略。

function cloneRule (rule) {
  const { resource, resourceQuery } = rule
  // Assuming `test` and `resourceQuery` tests are executed in series and
  // synchronously (which is true based on RuleSet's implementation), we can
  // save the current resource being matched from `test` so that we can access
  // it in `resourceQuery`. This ensures when we use the normalized rule's
  // resource check, include/exclude are matched correctly.
  let currentResource
  const res = Object.assign({}, rule, {
    resource: {
      test: resource => {
        currentResource = resource
        return true
      }
    },
    resourceQuery: query => {
      const parsed = qs.parse(query.slice(1))
      if (parsed.vue == null) {
        return false
      }
      if (resource && parsed.lang == null) {
        return false
      }
      const fakeResourcePath = `${currentResource}.${parsed.lang}`
      if (resource && !resource(fakeResourcePath)) {
        return false
      }
      if (resourceQuery && !resourceQuery(query)) {
        return false
      }
      return true
    }
  })

  if (rule.rules) {
    res.rules = rule.rules.map(cloneRule)
  }

  if (rule.oneOf) {
    res.oneOf = rule.oneOf.map(cloneRule)
  }

  return res
}

resource中的test过滤规则,直接返回true。其中关键的是resourceQuery,看一下官方文档的例子

与资源查询相匹配的 Condition。此选项用于测试请求字符串的查询部分(即从问号开始)。如果你需要通过 import Foo from './foo.css?inline' 导入 Foo,则需符合以下条件:

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\\.css$/,
        resourceQuery: /inline/,
        use: 'url-loader',
      },
    ],
  },
};

则我们复制后的rule的规则就是

  • 参数里开头是?vue且需要存在lang
  • 而且如果将路径改成以lang为后缀的文件,要能进过原本的resource校验,当然参数也要经过原本的resourceQuery校验。
    比如app.vue?vue&lang=js,则“app.js”文件路径需要经过原本loader的resourceQuery校验。

那搞这些是为啥呢?
其实就是为了之后vue中的script标签内容和css内容能经过用户配置的js和相关的css loader。
剧透一下,vue-loader会将资源路径转换成类似下面的样子

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

这样就解决了我们的一个疑问。

总结一下

通过复制rule,再重写resourceQuery,这样能够匹配一些具体引用路径的文件。
比如"./App.vue?vue&type=script&lang=js&"这个内容时,webpack核心流程会根据我们编写的rule过滤出匹配到的loader数组(包含pitcher)。
然后经过pitcher处理时,获取匹配到的loader数组,转换成内联写法。后面的这样我们的vue文件中的script内容才可以被设置好的jsloader处理。
没理解?看下面pitcher怎么说吧~~

pitcher

官方解释
Pitching Loader
正常情况下,rule中匹配到,会从右向左执行loader。

module.exports = {
  //...
  module: {
    rules: [
      {
        //...
        use: ['a-loader', 'b-loader', 'c-loader'],
      },
    ],
  },
};

而实际上流程是这样的

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

会先从左到右执行loader的pitch,如果pitch有具体的return,会中断后续的loader。
比如a-loader pitch如果有返回内容,则源数据之后经过`a-loader `pitch处理,就完成了。
那插件加了这个pitcher匹配了啥?做了啥?下面代码就是增加的pitch这个loader的webpack配置。

// global pitcher (responsible for injecting template compiler loader & CSS
    // post loader)
    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
      }
    }

看了代码后,知道匹配的规则是第一个参数是?vue。处理的loader是'./loaders/pitcher'。
而pitcher.js代码的核心就是,导出一个pitch函数,而默认loader处理是直接返回代码内容。
pitcher.js入口文件:

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) {
  ...
  // 当前请求资源,匹配到的所有loader
  let loaders = this.loaders
  ...
  // 遍历对应loader生成内联字符串,这里会做去重操作,因为之前plugin插件复制的rule和原始rule都会匹配到,所以为了避免一个请求路径被同一个loader处理两次,需要去重
  const genRequest = loaders => {
  	...
  }
  // Inject style-post-loader before css-loader for scoped CSS and trimming
  if (query.type === `style`) {
  	...
    return ...
  }
  // for templates: inject the template compiler & optional cache
  if (query.type === `template`) {
    ...
    return ...
  }
  ...
  // When the user defines a rule that has only resourceQuery but no test,
  // both that rule and the cloned rule will match, resulting in duplicated
  // loaders. Therefore it is necessary to perform a dedupe here.
  const request = genRequest(loaders)
  const scode = `import mod from ${request}; export default mod; export * from ${request}`
  return scode
}

整个pitch方法,逻辑也比较清晰,经过规则匹配的文件路径会经过此loader的pitch处理,而这个pitch是有返回值的,所以它的返回内容不会再经过其他loader。
它针对几种情况,有不同的返回。

情况一:如果路径是?vue&type=style

源代码:

if (query.type === `style`) {
    const cssLoaderIndex = loaders.findIndex(isCSSLoader)
    if (cssLoaderIndex > -1) {
      const afterLoaders = loaders.slice(0, cssLoaderIndex + 1)
      const beforeLoaders = loaders.slice(cssLoaderIndex + 1)
      // genRequest根据webpack匹配到的几个loader,转换成内联写法的字符串
      const request = genRequest([
        ...afterLoaders,
        stylePostLoaderPath,
        ...beforeLoaders
      ])
      return query.module
        ? `export { default } from  ${request}; export * from ${request}`
        : `export * from ${request}`
    }
  }

上面代码中的loaders,是当前引用的文件路径匹配到的所有loader。 整个逻辑块做的是将匹配到的loader转成内联写法的字符串。然后返回一个默认导出的字符串。
比如 import a from ''../a.vue?vue&type=style,经过这个loader的pitch函数作用后。我import的文件内容变成了
export * from ${request},而request就是匹配a.vue?vue&type=style这个路径规则的loader的内联写法。
这里直接输出一下,当前匹配到啥loader。
假设,vue-loader转换了一波路径变成下面这样。(看到后面会知道为什么会变成 import a from './App.vue' 会变成下面这样)
import style from "/Users/.../src/components/Detail.vue?vue&type=style&index=0&id=a38ba3fe&scoped=true&lang=less&"
这路径经过webpack处理会匹配到啥?看下面,太清楚明白了。

path: '/Users/.../src/components/Detail.vue?vue&type=style&index=0&id=a38ba3fe&scoped=true&lang=less&',
  loaders: [
    {
      path: '/Users/.../node_modules/_vue-style-loader@4.1.3@vue-style-loader/index.js',
      ...
    },
    {
      path: '/Users/.../node_modules/_css-loader@3.6.0@css-loader/dist/cjs.js',
      ...
    },
    {
      path: '/Users/.../node_modules/_postcss-loader@3.0.0@postcss-loader/src/index.js',
			...
    },
    {
      path: '/Users/.../node_modules/_less-loader@5.0.0@less-loader/dist/cjs.js',
      ...
    },
    {
      path: '/Users/.../node_modules/_vue-loader@15.9.7@vue-loader/lib/index.js',
      ...
    }
  ]

源代码的注释也很明白了(// Inject style-post-loader before css-loader for scoped CSS and trimming),找到css-loader,在它之前插入style-post-loader,目的就是为了做scoped和修剪。
然后将处理好的loader数组通过一个genRequest函数,返回一个字符串路径。后面我们再看看具体变成什么样。
先了解一下stylePostLoaderPath这个loader。

...
// stylePostLoaderPath
module.exports = function (source, inMap) {
  const query = qs.parse(this.resourceQuery.slice(1))
  const { code, map, errors } = compileStyle({
    source,
    filename: this.resourcePath,
    id: `data-v-${query.id}`,
    map: inMap,
    scoped: !!query.scoped,
    trim: true
  })

  if (errors.length) {
    this.callback(errors[0])
  } else {
    this.callback(null, code, map)
  }
}

而stylePostLoaderPath做的就是根据路径参数scoped是否为true,如果是id值就做为scopedid,重新修改style样式,给每个选择器加上属性选择器。例如

.p-class{
	font-size:20px
}
==>>
.p-class[data-v-xxxxxx]{
	font-size:20px
}

这样再配合vue-loader最中导出component组件中配置的hasScoped,使用同样的id,在运行时阶段就会通过将vnode创建成真实dom时拼接上dom的属性中。

情况二:如果路径是?vue&type=template

看看会匹配到什么loader,一般情况下只有一个vue-loader。

loaders: [
    {
      path: '/Users/***/node_modules/_vue-loader@15.9.7@vue-loader/lib/index.js',
      query: '??vue-loader-options',
			...
    }
  ]

再看看源码是怎么处理template相关的loader数组。最关键是👇下面的templateLoaderPath这个loader,它的作用就是生成我们熟悉的render函数

// for templates: inject the template compiler & optional cache
  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
    ])
    // the template compiler uses esm exports
    return `export * from ${request}`
  }

templateLoaderPath这个loader它是怎么生成render函数的呢?里面引用了一个关键的vue-template-compiler包,这里不对源码展开,直接看它能产出什么,看一下官方解释。
compiler.compile(template, [options])

Compiles a template string and returns compiled JavaScript code. The returned result is an object of the following format:

{
  ast: ?ASTElement, // parsed template elements to AST 模板ast
  render: string, // main render function code render函数
  staticRenderFns: Array<string>, // render code for static sub trees, if any 静态子树
  errors: Array<string> // template syntax errors, if any 异常
}

而这个vue-template-compiler来源也不完全是引入npm包,在整个vue-loader中的是可以通过配置传递进来的。这就给了我们很大的可能,让我们能在编译阶段获得ast,render函数。甚至改变它

const compiler = options.compiler || require('vue-template-compiler')

情况三:如果路径是?vue&type=js

在源码中,特殊处理了type=styletype=template的情况,而js则是默认处理的情况。
看看默认下匹配到了啥loader

loaders: [
    {
      path: '/Users/.../node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js',
      ...
    },
    {
      path: '/Users/.../node_modules/_babel-loader@8.2.2@babel-loader/lib/index.js',
      ...
    },
    {
      path: '/Users/.../node_modules/_vue-loader@15.9.7@vue-loader/lib/index.js',
      ...
    }
  ]

返回处理,然后我们再看看返回了啥,request是什么。

// When the user defines a rule that has only resourceQuery but no test,
  // both that rule and the cloned rule will match, resulting in duplicated
  // loaders. Therefore it is necessary to perform a dedupe here.
  const request = genRequest(loaders)
  const scode = `import mod from ${request}; export default mod; export * from ${request}`
  return scode
request:"-!../node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--12-0!../node_modules/_babel-loader@8.2.2@babel-loader/lib/index.js!../node_modules/_vue-loader@15.9.7@vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&"

总结一下pitcher

  1. 根据之前插件复制出来的rules,在匹配路径?vue&type=xx时,进入这个pitcher-loader
  2. 此loader有pitch函数返回,所以不会将结果交给其他匹配到的loader继续处理
  3. 根据三种type,处理返回不同的内容。也不是直接处理代码内容,而是生成新的引用写法,串联不同loader。(注意内联的第一个处理loader是vue-loader)
  4. 之后会进入新的webpack匹配中,这之后才是真正意义上的处理我们的vue源码文件


vue-loader(入口文件)

简化版本,先看一遍代码和注释。

const { parse } = require('@vue/component-compiler-utils')
...
module.exports = function (source) {
  const loaderContext = this
	// 配套plugin插件如果没配置会报错
  if (!errorEmitted && !loaderContext['thread-loader'] && !loaderContext[NS]) {
  }
	...
  // 根据上下文获取当前匹配到资源的相关信息
  const {
    target,
    request,
    minimize,
    sourceMap,
    rootContext,
    resourcePath,
    resourceQuery = ''
  } = loaderContext
  
    // 根据上下文和插件配置提取信息
  ...
  
  // source和解析配置经过@vue/component-compiler-utils处理,返回描述内容
  const descriptor = parse({
    source,
    compiler: options.compiler || loadTemplateCompiler(loaderContext),
    filename,
    sourceRoot,
    needMap: sourceMap
  })

  // if the query has a type field, this is a language block request
  // e.g. foo.vue?type=template&id=xxxxx
  // and we will return early
  // 如果依赖路径存在type参数,直接经过selectBlock处理返回
  if (incomingQuery.type) {
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }

  // module id for scoped CSS & hot-reload
  // scoped和热更新逻辑
  ...
  const id = hash(
    isProduction
      ? (shortFilePath + '\n' + source.replace(/\r\n/g, '\n'))
      : shortFilePath
  )

  // feature information
  const hasScoped = descriptor.styles.some(s => s.scoped)
  ...

  // template
  let templateImport = `var render, staticRenderFns`
  let templateRequest
  if (descriptor.template) {
    ...
    // 处理template
  }
  // script
  let scriptImport = `var script = {}`
  if (descriptor.script) {
    ...
    // 处理script
  }
  // styles
  let stylesCode = ``
  if (descriptor.styles.length) {
    ...
    处理styles
  }

  let code = `
${templateImport}
${scriptImport}
${stylesCode}

/* normalize component */
import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
  script,
  render,
  staticRenderFns,
  ${hasFunctional ? `true` : `false`},
  ${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
  ${hasScoped ? JSON.stringify(id) : `null`},
  ${isServer ? JSON.stringify(hash(request)) : `null`}
  ${isShadow ? `,true` : ``}
)
  `.trim() + `\n`
  ...
  // 热更新相关内容
  if (needsHotReload) {
    code += `\n` + genHotReloadCode(id, hasFunctional, templateRequest)
  }

  ...

  code += `\nexport default component.exports`
  // 返回处理后的code
  return code
}

module.exports.VueLoaderPlugin = plugin

看完入口文件,大概有几点关键内容

  • webpack在经过loader处理时,传入的上下文,会包含资源的一些信息。
  • @vue/component-compiler-utils的parse处理后返回的表述内容都会是啥?
  • 存在一个incomingQuery.type判断,会return处理结果(也是关键的一步,先留意一下)
  • 生成了css的scopedid
  • 处理了文件内的三块标签,处理了啥?
  • 返回处理完的字符串code

一个个看看这里的关键信息都做了啥

loaderContext

进入源码,打印出来看看

{
  target: 'web', // 打包目标环境
  request: '/Users/.../node_modules/_vue-loader@15.9.7@vue-loader/lib/index.js??vue-loader-options!/Users/.../node_modules/_eslint-loader@2.2.1@eslint-loader/index.js??ref--13-0!/Users/.../src/App.vue', // 当前依赖匹配loader的内联方式表示
  minimize: undefined,
  sourceMap: true,
  rootContext: '/Users/...', // 当前vue项目根目录
  resourcePath: '/Users/.../src/App.vue', // 引入依赖绝对路径
  resourceQuery: '' // 依赖路径上的参数
}

这些基础信息也很关键,特别是resourceQuery,这里的参数将作为后续逻辑处理的重要判断。

@vue/component-compiler-utils的parse处理

@vue/component-compiler-utils这个用来编译vue文件的底层工具,具体的转换逻辑就是在这里进行。里面做了source map的映射处理。css scoped的处理。还有最重要的template标签内容处理。
核心能力利用vue-template-compiler包template => ast => render函数,有了文件内容的ast描述,才能生成对应的render函数,提供给vue运行时渲染视图。而ast描述也给我们增加了更多可能,能够在边缘阶段知道文件都编写了什么内容。
但是这里处理tempalte生成渲染函数并不是在这段代码中运行的,这里只是接收options中的配置内容。我们再看看vue-template-compiler的相关配置和能力
先看看处理前接收什么参数

const { parse } = require('@vue/component-compiler-utils')
...
const descriptor = parse({
    source, // 文件内容
    compiler: options.compiler || loadTemplateCompiler(loaderContext), // compiler配置,这里的options.compiler就是vue-template-compiler
    filename, // 文件名称 'App.vue'
    sourceRoot, // 文件目录 'src'
    needMap: sourceMap
})

看看输出结果是啥

descriptor: {
    template: {
      type: 'template',
      content: '\n' +
        '<div id="app">\n' +
					...
        '</div>\n',
      start: 10,
      attrs: {},
      end: 569
    },
    script: {
      type: 'script',
      content: '//\n' +
					...
        '}\n',
      start: 590,
      attrs: {},
      end: 2251,
      map: [Object]
    },
    styles: [ {
    	type: 'styles',
      content: '//\n' +
					...
        '}\n',
      start: 2280,
      attrs: {lang: 'less' },
      lang: 'less',
      end: 3133,
      map: [Object]
    } ],
    customBlocks: [],
    errors: []
  }

输出了每个标签模块的源内容。但是这里的输出只是基本的解析,其实解析成render函数的地方不是这。而是之前看的pitch-loader中pitcher阶段里转变成内联loader时增加的templateLoader

incomingQuery.type

这个是啥?看下面的源码,至少vue-loader处理的结果有两种情况的返回。这是很关键的点哦

const {
    target,
    request,
    minimize,
    sourceMap,
    rootContext,
    resourcePath,
    resourceQuery = ''
  } = loaderContext
  const rawQuery = resourceQuery.slice(1)
  const inheritQuery = `&${rawQuery}`
  const incomingQuery = qs.parse(rawQuery)
 	...
  if (incomingQuery.type) {
    console.log({
      path:this.resourcePath,
      resourceQuery: this.resourceQuery,
      incomingQuery,
      path: '~~~~~~~~~~'
    })
    return selectBlock(
      descriptor,
      loaderContext,
      incomingQuery,
      !!options.appendExtension
    )
  }
	...
  // code 处理组合...
  ...
  return code
  

我们可以打断点,或console输出看看这里都是啥。这里输出的是当前处理的引用资源路径。

{
  path: '/Users/.../src/App.vue',
  resourceQuery: '?vue&type=template&id=7ba5bd90&',
  incomingQuery: [Object: null prototype] {
    vue: '',
    type: 'template',
    id: '7ba5bd90'
  },
  path: '~~~~~~~~~~'
}

可以看出,引用路径大概是/Users/.../src/App.vue?vue&type=template&id=7ba5bd90&,而如果存在参数type就会进入这种返回。通过selectBlock函数返回处理结果。
其实这里就是依据之前的descriptor结果,获得解析分割好的template、script、style部分的源代码。

vue文件主要部分(template、script、style)

直接看看简单点看看,import App form './App.vue',处理成了啥吧
之前的源码返回的部分,前几行代码是👇,我们直接输出结果,再分析,这三个变量是怎么转换的。

${templateImport}
${scriptImport}
${stylesCode}

处理结果如下,先重点看前几行。
其实就是将vue组件内的三大部分引入进来,引入路径不光光是.vue还要增加相应的type参数和lang参数。新的引入路径交给别的loader处理,就能拿到指定的返回内容。
然后将这些内容交给runtime的componentNormalizer处理,就是我们组件的完整内容,包含各类配置和render函数。

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


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

/* hot reload */
if (module.hot) {
  var api = require("/Users/gankai/k-file/myProject/科技防疫/prevention2/node_modules/_vue-hot-reload-api@2.3.4@vue-hot-reload-api/dist/index.js")
  api.install(require('vue'))
  if (api.compatible) {
    module.hot.accept()
    if (!api.isRecorded('7ba5bd90')) {
      api.createRecord('7ba5bd90', component.options)
    } else {
      api.reload('7ba5bd90', component.options)
    }
    module.hot.accept("./App.vue?vue&type=template&id=7ba5bd90&", function () {
      api.rerender('7ba5bd90', {
        render: render,
        staticRenderFns: staticRenderFns
      })
    })
  }
}
component.options.__file = "src/App.vue"
export default component.exports

总结

这里我们vue-loader做的主要的逻辑都理清楚了,但是对于全局我们还没串联起来,先不急,先总结一下vue-loader能做啥事。

  1. webpack在经过loader处理时,传入的上下文,会输出包含引用资源的一些信息,路径参数等等
  2. parse处理,会返回vue源文件的每个部分的代码内容。
  3. vue-loader有两种返回方式,如果路径存在type参数。直接通过selectBlock返回结果。另一种情况,没有直接返回vue文件源代码内容。而是生成新的引用路径然后再拼接一些字符串内容返回,这些新内容的引用路径将会再次通过webpack解析

流程给你画出来!!再总结一下

好了好了,关键逻辑处理都看了代码啦。试着梳理一下整个流程是怎么进行的。vue-loader和插件是怎么配合,pitcher又是在什么环节使用的。
webpack.png
差不多完了,整体都比较清晰了。看看开头我自己的几个疑惑有没有解决。

  1. loader怎么解析vue文件的,它在webpack中的工作流程大概是啥?这是我最想了解的~~
    上图👆
  2. 为什么vue-loader要配合VueLoaderPlugin插件一起使用?
    插件打杂,要复制新的rule。
  3. 它怎么处理template标签内的类html语法,转化成render函数?
    可以再看一下pitcher对template的处理,pitcher阶段里转变成内联写法loader时增加的
    **templateLoader**
  4. 它怎么处理css或其他扩展语言的?顺便了解一下scoped属性是怎么作用在当前组件的。
    vue-loader第一次处理,增加scopedid,pitcher处理后,依次处理css文件,其中stylePostLoader根据参数处理css代码,增加属性选择器。
  5. 最后也有一个比较大的疑问,为什么script标签和css标签内的内容,还能被别的loader处理,那些babel-loader等不是只匹配处理js文件或css文件吗?
    插件复制出来的loader处理的,带lang属性的就能匹配到对应的loader,然后pitcher改写成内联写法,逐一处理。
  6. 我还能做些啥?感觉挺牛b的啦,对我具体工作应该也能有些帮助吧。
    在工作中,我们可能较少的业务会直接改动到vue-loader的代码,但是知道整个流程后。如果需要拓展改造,这就没什么难度了。如果项目中需要我们主动解析本地代码,去做一些构建时的处理,同步配置、特殊构建等。我们就有了入手点,vue-loader中的vue-template-compiler配置就是一个点,它能解析template内容,生成ast和render函数,这里我们就有机会对项目进行特殊的处理。

其他

在我反复研究流程后,还是发现不少疑惑,一下没太明白的问题。

  1. 为什么还要pitcher?vue-loader直接处理内联写法不行吗?
  2. 插件复制rule做什么?我已经知道需要复制的rule能处理什么文件类型,直接转?

自己也没太明白,后面给了自己几个相对合理的解释,不知道是否如此。

  1. 尝试过直接在vue-loader第一次处理时,返回pitcher处理后的内容。发现流程也是可行的。但是这是我知道在pitcher处理后,具体的需要的loader是哪些的前提下可以直接返回。如果我们直接便利rule,去检索相关的匹配条件,直接拼接内联的loader写法,是可以实现的。但vue没那么做,我感觉是因为这些匹配规则和写法其实是webpack主导配置的,它有自己的匹配逻辑,所以应该是遵循webpack的rule写法,然后在this.loader下自然能获取到匹配到的loader。这样才是符合逻辑和标准的,如果自己写匹配逻辑,可能会因为webpack的升级和写法的改变而出错。这就导致vue-loader的第一次处理不能直接转换内联写法(毕竟第一次处理匹配的是.vue后缀的文件)。
    那么我们就需要一个loader,在.vue文件的loader处理后,是能第一个接触到处理结果的loader,再根据上下文的loader数组进行内联处理,处理结果还不想被其他loader改变,只能有pitch函数承担这个责任。
  2. 所以,在第一点的背景下,是有必要遵循webpack的rule匹配逻辑。当我们想匹配带参数的路径,就需要编写相应的rule规则。所以自然就需要新的rule。这样后续的pitcher才能在webpack构建的流程中获得loader数组。