最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Webpack 案例 —— vue-loader 原理分析

    正文概述 掘金(字节前端)   2021-03-08   862

    OK,如果你不是特别清楚,那接着往下看吧,下面我们会拆开vue-loader的代码,看看SFC内容具体是怎么流转转换,顺便还能学学 webpack loader 的编写套路。

    概述

    vue-loader 主要包含三部分:

    1. lib/index.js 定义的 normal loader
    2. lib/loaders/pitcher.js 定义的 pitcher loader
    3. lib/plugin.js 定义的插件

    三者协作共同完成对SFC的处理,使用时需要用户同时注册normal loader和plugin,简单示例:

    const VueLoaderPlugin = require("vue-loader/lib/plugin");
    
    module.exports = {
      module: {
        rules: [
          {
            test: /.vue$/,
            use: [{ loader: "vue-loader" }],
          }
        ],
      },
      plugins: [
        new VueLoaderPlugin()
      ],
    };
    

    运行过程可以粗略总结为两个阶段:

    1. 预处理阶段:在插件 apply 函数动态修改 webpack 配置,注入 vue-loader 专用的 rules
    2. 内容处理阶段:normal loader 配合 pitcher loader 完成文件内容转换

    插件预处理阶段

    vue-loader 插件会在apply函数中扩展webpack配置信息核心代码如下:

    class VueLoaderPlugin {
      apply (compiler) {
        // ...
    
        const rules = compiler.options.module.rules
        // ...
    
        const clonedRules = rules
          .filter(r => r !== rawVueRules)
          .map((rawRule) => cloneRule(rawRule, refs))
    
        // ...
    
        // global pitcher (responsible for injecting template compiler loader & CSS
        // post loader)
        const pitcher = {
          loader: require.resolve('./loaders/pitcher'),
          resourceQuery: query => {
            if (!query) { return false }
            const parsed = qs.parse(query.slice(1))
            return parsed.vue != null
          }
          // ...
        }
    
        // replace original rules
        compiler.options.module.rules = [
          pitcher,
          ...clonedRules,
          ...rules
        ]
      }
    }
    
    function cloneRule (rawRule, refs) {
        // ...
    }
    
    module.exports = VueLoaderPlugin
    

    拆开来看,插件主要完成两个任务:

    1. 初始化pitcher

    如代码第16行,定义pitcher对象,指定loader路径为 require.resolve('./loaders/pitcher') ,并将pitcher注入到 rules 数组首位。

    这种动态注入的好处是用户不用关注 —— 不去看源码根本不知道还有一个pitcher loader,而且能保证pitcher能在其他rule之前执行,确保运行顺序。

    1. 复制rules列表

    如代码第8行,plugin 中遍历 compiler.options.module.rules 数组,也就是用户提供的webpack配置中的 module.rules 项,对每个rule执行 cloneRule 方法复制规则对象。之后,将webpack 配置修改为 [pitcher, ...clonedRules, ...rules] 。

    感受一下实际效果,例如对于rules配置:

    module.exports = {
      module: {
        rules: [
          {
            test: /.vue$/i,
            use: [{ loader: "vue-loader" }],
          },
          {
            test: /.css$/i,
            use: [MiniCssExtractPlugin.loader, "css-loader"],
          },
          {
            test: /.js$/i,
            exclude: /node_modules/,
            use: {
              loader: "babel-loader",
              options: {
                presets: [["@babel/preset-env", { targets: "defaults" }]],
              },
            },
          },
        ],
      },
      plugins: [
        new VueLoaderPlugin(),
        new MiniCssExtractPlugin({ filename: "[name].css" }),
      ],
    };
    

    这里定义了三个rule,分别对应vue、js、css文件。经过 plugin 转换之后的结果大概为:

    module.exports = {
      module: {
        rules: [
          {
            loader: "/node_modules/vue-loader/lib/loaders/pitcher.js",
            resourceQuery: () => {},
            options: {},
          },
          {
            resource: () => {},
            resourceQuery: () => {},
            use: [
              {
                loader: "/node_modules/mini-css-extract-plugin/dist/loader.js",
              },
              { loader: "css-loader" },
            ],
          },
          {
            resource: () => {},
            resourceQuery: () => {},
            exclude: /node_modules/,
            use: [
              {
                loader: "babel-loader",
                options: {
                  presets: [["@babel/preset-env", { targets: "defaults" }]],
                },
                ident: "clonedRuleSet-2[0].rules[0].use",
              },
            ],
          },
          {
            test: /.vue$/i,
            use: [
              { loader: "vue-loader", options: {}, ident: "vue-loader-options" },
            ],
          },
          {
            test: /.css$/i,
            use: [
              {
                loader: "/node_modules/mini-css-extract-plugin/dist/loader.js",
              },
              { loader: "css-loader" },
            ],
          },
          {
            test: /.vue$/i,
            exclude: /node_modules/,
            use: [
              {
                loader: "babel-loader",
                options: {
                  presets: [["@babel/preset-env", { targets: "defaults" }]],
                },
                ident: "clonedRuleSet-2[0].rules[0].use",
              },
            ],
          },
        ],
      },
    };
    

    转换之后生成6个rule,按定义的顺序分别为:

    1. 针对 xx.vue&vue 格式路径生效的规则,只用了 Vue-loader 的 pitcher 作为loader
    2. 被复制的css处理规则,use 数组与开发者定义的规则相同
    3. 被复制的js处理规则,use 数组也跟开发者定义的规则相同
    4. 开发者原始定义的vue-loader规则,内容及配置都不变
    5. 开发者原始定义的css规则,用到 css-loader、mini-css-extract-plugin loader
    6. 开发者原始定义的js规则,用到 babel-loader

    可以看到,第2、3项是从开发者提供的配置中复制过来的,内容相似,只是 cloneRule 在复制过程会给这些规则重新定义 resourceQuery 函数:

    unction cloneRule (rawRule, refs) {
        const rules = ruleSetCompiler.compileRules(`clonedRuleSet-${++uid}`, [{
          rules: [rawRule]
        }], refs)
      
        const conditions = rules[0].rules
          .map(rule => rule.conditions)
          // shallow flat
          .reduce((prev, next) => prev.concat(next), [])
    
        // ...
      
        const res = Object.assign({}, rawRule, {
          resource: resources => {
            currentResource = resources
            return true
          },
          resourceQuery: query => {
            if (!query) { return false }
            const parsed = qs.parse(query.slice(1))
            if (parsed.vue == null) {
              return false
            }
            if (!conditions) {
              return false
            }
            // 用import路径的lang参数测试是否适用于当前rule
            const fakeResourcePath = `${currentResource}.${parsed.lang}`
            for (const condition of conditions) {
              // add support for resourceQuery
              const request = condition.property === 'resourceQuery' ? query : fakeResourcePath
              if (condition && !condition.fn(request)) {
                return false
              }
            }
            return true
          }
        })
        // ...
      
        return res
      }
    

    cloneRule 内部定义的 resourceQuery 函数对应 module.rules.resourceQuery 配置项,与我们经常用的 test 差不多,都用于判断资源路径是否适用这个rule。这里 resourceQuery 核心逻辑就是取出路径中的lang参数,伪造一个以 lang 结尾的路径,传入rule的condition中测试路径名对该rule是否生效,例如下面这种会命中 /.js$/i 规则:

    import script from "./index.vue?vue&type=script&lang=js&"
    

    Vue-loader 正是基于这个规则,为不同内容块 (css/js/template) 匹配、复用用户所提供的 rule 设置。

    SFC 内容处理阶段

    概述

    插件处理完配置,webpack 运行起来之后,vue SFC 文件会被多次传入不同的loader,经历多次中间形态变换之后才产出最终的js结果,大致上可以分为如下步骤:

    1. 路径命中 /.vue$/i 规则,调用 vue-loader 生成中间结果A
    2. 结果A命中 xx.vue?vue 规则,调用 vue-loader pitcher 生成中间结果B
    3. 结果B命中具体loader,直接调用loader做处理

    过程大致为:

    Webpack 案例 —— vue-loader 原理分析

    举个转换过程的例子:

    // 原始代码
    import xx from './index.vue';
    // 第一步,命中 vue-loader,转换为:
    import { render, staticRenderFns } from "./index.vue?vue&type=template&id=2964abc9&scoped=true&"
    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&id=2964abc9&scoped=true&lang=css&"
    
    // 第二步,命中 pitcher,转换为:
    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&scoped=true&"
    import mod from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&"; 
    export default mod; export * from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&"
    export * from "-!../../node_modules/mini-css-extract-plugin/dist/loader.js!../../node_modules/css-loader/dist/cjs.js!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=style&index=0&id=2964abc9&scoped=true&lang=css&"
    
    // 第三步,根据行内路径规则按序调用loader
    

    每一步的细节,请继续往下看。

    1. 第一次执行 vue-loader

    在运行阶段,根据配置规则, webpack 首先将原始的SFC内容传入vue-loader,例如对于下面的代码:

    // main.js
    import xx from 'index.vue';
    
    // index.vue 代码
    <template>
      <div class="root">hello world</div>
    </template>
    
    <script>
    export default {
      data() {},
      mounted() {
        console.log("hello world");
      },
    };
    </script>
    
    <style scoped>
    .root {
      font-size: 12px;
    }
    </style>
    

    此时第一次执行 vue-loader ,执行如下逻辑:

    1. 调用 @vue/component-compiler-utils 包的parse函数,将SFC 文本解析为AST对象
    2. 遍历 AST 对象属性,转换为特殊的引用路径
    3. 返回转换结果

    对于上述 index.vue 内容,转换结果为:

    import { render, staticRenderFns } from "./index.vue?vue&type=template&id=2964abc9&scoped=true&"
    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&id=2964abc9&scoped=true&lang=css&"
    
    
    /* normalize component */
    import normalizer from "!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js"
    var component = normalizer(
      script,
      render,
      staticRenderFns,
      false,
      null,
      "2964abc9",
      null
      
    )
    
    ...
    export default component.exports
    

    注意,这里并没有真的处理 block 里面的内容,而是简单地针对不同类型的内容块生成import语句:

    • Script:"./index.vue?vue&type=script&lang=js&"
    • Template: "./index.vue?vue&type=template&id=2964abc9&scoped=true&"
    • Style: "./index.vue?vue&type=style&index=0&id=2964abc9&scoped=true&lang=css&"

    这些路径都对应原始的 .vue 路径基础上增加了 vue 标志符及 type、lang 等参数。

    1. 执行pitcher

    如前所述,vue-loader 插件会在预处理阶段插入带 resourceQuery 函数的 pitcher 对象:

    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        if (!query) { return false }
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      }
    }
    

    其中, resourceQuery 函数命中 xx.vue?vue 格式的路径,也就是说上面vue-loader 转换后的import 路径会被pitcher命中,做进一步处理。pitcher 的逻辑比较简单,做的事情也只是转换import路径:

    const qs = require('querystring')
    ...
    
    const dedupeESLintLoader = loaders => {...}
    
    const shouldIgnoreCustomBlock = loaders => {...}
    
    // 正常的loader阶段,直接返回结果
    module.exports = code => code
    
    module.exports.pitch = function (remainingRequest) {
      const options = loaderUtils.getOptions(this)
      const { cacheDirectory, cacheIdentifier } = options
      // 关注点1: 通过解析 resourceQuery 获取loader参数
      const query = qs.parse(this.resourceQuery.slice(1))
    
      let loaders = this.loaders
    
      // if this is a language block request, eslint-loader may get matched
      // multiple times
      if (query.type) {
        // if this is an inline block, since the whole file itself is being linted,
        // remove eslint-loader to avoid duplicate linting.
        if (/.vue$/.test(this.resourcePath)) {
          loaders = loaders.filter(l => !isESLintLoader(l))
        } else {
          // This is a src import. Just make sure there's not more than 1 instance
          // of eslint present.
          loaders = dedupeESLintLoader(loaders)
        }
      }
    
      // remove self
      loaders = loaders.filter(isPitcher)
    
      // do not inject if user uses null-loader to void the type (#1239)
      if (loaders.some(isNullLoader)) {
        return
      }
    
      const genRequest = loaders => {
        ... 
      }
    
      // Inject style-post-loader before css-loader for scoped CSS and trimming
      if (query.type === `style`) {
        const cssLoaderIndex = loaders.findIndex(isCSSLoader)
        if (cssLoaderIndex > -1) {
          ...
          return query.module
            ? `export { default } from  ${request}; export * from ${request}`
            : `export * from ${request}`
        }
      }
    
      // for templates: inject the template compiler & optional cache
      if (query.type === `template`) {
        .​..
        // console.log(request)
        // the template compiler uses esm exports
        return `export * from ${request}`
      }
    
      // if a custom block has no other matching loader other than vue-loader itself
      // or cache-loader, we should ignore it
      if (query.type === `custom` && shouldIgnoreCustomBlock(loaders)) {
        return ``
      }
    
      const request = genRequest(loaders)
      return `import mod from ${request}; export default mod; export * from ${request}`
    }
    

    核心功能是遍历用户定义的rule数组,拼接出完整的行内引用路径,例如:

    // 开发代码:
    import xx from 'index.vue'
    // 第一步,通过vue-loader转换成带参数的路径
    import script from "./index.vue?vue&type=script&lang=js&"
    // 第二步,在 pitcher 中解读loader数组的配置,并将路径转换成完整的行内路径格式
    import mod from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&";
    
    1. 第二次执行vue-loader

    通过上面 vue-loader -> pitcher 处理后,会得到一个新的行内路径,例如:

    import mod from "-!../../node_modules/babel-loader/lib/index.js??clonedRuleSet-2[0].rules[0].use!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./index.vue?vue&type=script&lang=js&";
    

    以这个import语句为例,之后webpack会按照下述逻辑运行:

    • 调用 vue-loader 处理 index.js 文件
    • 调用 babel-loader 处理上一步返回的内容

    这就给了 vue-loader 第二次执行的机会,再回过头来看看 vue-loader 的代码:

    module.exports = function (source) {
      // ...
    
      const {
        target,
        request,
        minimize,
        sourceMap,
        rootContext,
        resourcePath,
        resourceQuery = "",
      } = loaderContext;
      // ...
    
      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
      if (incomingQuery.type) {
        return selectBlock(
          descriptor,
          loaderContext,
          incomingQuery,
          !!options.appendExtension
        );
      }
      //...
      return code;
    };
    
    module.exports.VueLoaderPlugin = plugin;
    

    第二次运行时由于路径已经带上了 type 参数,会命中上面第26行的判断语句,进入 selectBlock 函数,这个函数的逻辑很简单:

    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
      if (query.type === `script`) {
        if (appendExtension) {
          loaderContext.resourcePath += '.' + (descriptor.script.lang || 'js')
        }
        loaderContext.callback(
          null,
          descriptor.script.content,
          descriptor.script.map
        )
        return
      }
    
      // styles
      if (query.type === `style` && query.index != null) {
        const style = descriptor.styles[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[query.index]
        loaderContext.callback(
          null,
          block.content,
          block.map
        )
        return
      }
    }
    

    就只是根据type参数返回不能内容。

    总结

    OK,到这里我们可以解答文章最开始提到的问题:

    1. Vue SFC 文件包含多种格式的内容:style、script、template以及自定义block,vue-loader 如何分别处理这些内容?
    1. 针对不同内容块,vue-loader 如何复用其他loader?比如针对 less 定义的style块,vue-loader 是怎么调用 less-loader 加载内容的?

    此外,从 vue-loader 可以学到一些webpack 插件、loader的套路:

    • 可以在插件中动态修改webpack的配置信息
    • Loader 并不一定都要实实在在的处理文件的内容,也可以是返回一些更具体,更有指向性的新路径,以复用webpack的其他模块
    • 灵活使用 resourceQuery ,能够在loader中更精准地命中特定路径格式

    招聘硬广告

    急招!我们是字节跳动游戏前端团队,团队当前业务包括数个 DAU 千万量级的游戏中心化平台,覆盖今日头条、抖音、西瓜视频等等多个字节跳动宿主端;还有月流水千万级的创作者服务平台,是抖音官方最重要的游戏短视频分发平台和达人变现渠道。团队负责了这些项目产品,以及与之相关的运营后台、广告主服务平台、效率工具的前端研发,业务技术包括小程序、H5、Node 等多个方向,且在业务快速发展的同时,还在持续丰富技术支持场景。

    欢迎点击 传送门,

    或者直接邮箱:lupengyu@bytedance.com。


    起源地下载网 » Webpack 案例 —— vue-loader 原理分析

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元