不知道是谁 の blog

Theme Toggle

自己写一个webpack vue-loader简易版

本文介绍了如何从零开始实现一个简易的 Vue 单文件组件 (SFC) 的 webpack loader。通过解析和编译 SFC 的各个部分(template、script、style),并使用 Babel 处理生成的 JavaScript 代码,最终实现了对 Vue 组件的加载和处理。文中详细讲解了每个步骤,并提供了代码示例。

lastModified: 2024-10-30

2024-10-30: 包含部分错误内容
webpack5 支持直接使用ESM导入的语法分析, babel的作用将是应用相关转换配置, 不一定是为了兼容webpack解析.

前言

九月份提出辞职后, 心里多少有点放松, 到十月份离职期间几乎玩了一整个月. 再加上手指关节有点问题, 所以这段时间就很少接触电脑. 十月份中从沈阳回到了老家, 准备换个南方点的城市尝试找份工资, 为自己的履历增加点新鲜内容.

不过这快两个月的空闲时间, 啥都没干, 让我对之后的事情有点没底, 所以这两天就打算把之前一直想写的webpack的vue-loader简单实现写一下.

再过几天就准备出发去南方, 希望能找到一个好工作?

了解loader

loader 是 webpack 用来处理各类文件代码的模块, 比如 less, scss, css, js, ts 等. 将代码转换成 webpack 能够识别/处理的有效模块.

比如将 ESM 代码转换成可以识别的 CJS 代码, 或者将css转换成js代码等等.

vue-loader

vue-loader 是 vue 官方提供的一个 vue 单文件组件的 loader, 通过 vue-loader 可以将 vue 文件转换成js代码. loader 会将 sfc 的文件转换成可以处理的js文件, 然后交给 webpack 处理.

与 react 有所区别的是, react 有直接的 babel 支持转换, 但是 vue 的 sfc 需要各种特殊处理, 比如模板内容需要静态提升 setup 需要转换结构之类的, 无法直接通过 babel 进行转换.

而为了将 webpack 不能识别的代码转换为可识别的, 就需要使用 babel 进行处理, 因此 vue 的 sfc 处理起来就比 react 多一些.

比如:

react:

webpack.config.js

javascript

/**
 * @type { import('webpack').Configuration }
 */
module.exports = {
  // ....
  module: {
    rules: [
      {
        test: /(\.js|\.ts|\.tsx|\.jsx)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              babelrc: false,
              configFile: false,
              presets: ['@babel/preset-env', '@babel/preset-react']
            }
          }
        ] 
      }
    ]
  }
  // ....
}

vue:

webpack.config.js

javascript

/**
 * @type { import('webpack').Configuration }
 */
module.exports = {
  // ....
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
  // ....
}

可以看到 vue 需要 plugin 的支持来处理 sfc 文件, 而 react 则不需要.

VueLoaderPlugin

VueLoaderPlugin 是 vue-loader 的一个插件, 用于在 vue-loader 之前添加一些 loader, 比如需要转换 ES6 语法的 babel, 以及 pitch 用于处理 style, 因为 webpack 无法直接处理 css. 插件会将其他 rule 比如 js 的 rule 配置复制到 vue-loader 的前面, 用于处理 vue-loader 将 sfc 转换成 js 后的代码.

虽然听起来简单, 不过实际处理还是有不少的内容的, 根据 webpack 版本不同进行不同的配置, 对 rule 的复制, 以及热更新和对编译代码的配合之类, 还是挺复杂的.

loader 的步骤

一个简单的 loader 还是很容易实现的, loader 一般用来专门处理特定类型的文件, webpack 在调用 loader 的时候, 会将上一个 loader 的处理结果作为第一个参数传入到下一个 loader.

一般 vue 的 loader 配置只有一个 vue-loader, 所以第一个参数是 webpack 从源文件读取处理的源代码.

pitch 会在 loader 前先从前往后执行一次, 有返回值时中断当前 loader 链, 如果返回的是代码, 则会解析代码并且分析 require 引入按照代码再执行 loader.

比如:

loader.js

javascript

function loader(source) {
  return source;
}

loader.pitch = function () {
  const resourceQuery = new URLSearchParams(this.resourceQuery);
  const resourcePath = this.utils
    .contextify(
      this.context,
      this.utils.absolutify(this.context, this.resourcePath)
    )
    .replace(this.resourceQuery, '');
  if(resourceQuery.get('type') === 'css') {
    return `require('!css-loader!${resourcePath}.css')`;
  }
}

module.exports = loader;

以上代码会先执行 pitch, 然后在 query type 的值为 css 的时候, 通过 css-loader 处理同名 css 文件, 同时该 loader 链将停止向后调用, 并执行当前 loader 前的 loader. loader 部分不对源代码进行处理, 直接将源代码传递至下一个 loader.

尝试编写一个简单的 vue-loader

前面知道了 vue-loader 大概做了什么, 接下来就可以开始尝试实现一个简单的 vue-loader 了.

创建项目

起始当然是创建项目和安装依赖了:

bash

pnpm init
pnpm add webpack webpack-cli @vue/compiler-sfc @babel/core @babel/preset-env -D
pnpm add html-webpack-plugin babel-loader css-loader style-loader -D
pnpm add vue -S

然后创建以下文件目录结构:

  • src

-- App.vue

-- main.js

  • webpack

-- loaders

--- vue-loader

  • index.html

  • webpack.config.js

写入配置和测试代码

在 webpack.config.js 中添加以下配置:

webpack.config.js

javascript

const path = require('node:path');
const html = require('html-webpack-plugin');

/**
 * @type {import('webpack').Configuration}
 */
const config = {
  mode: 'development',
  entry: path.resolve(__dirname, './src/main.js'), // 入口文件
  output: {
    path: path.resolve(__dirname, 'dist'), // 输出路径
    clean: true,                           // 构建前清理输出目录
    assetModuleFilename: '[name][ext]',    // 静态资源文件名
    chunkFilename: '[name].js',            // 块文件名
    publicPath: './',                      // 静态资源引用路径
  },
  target: 'web',                           // 浏览器环境
  module: {
    rules: [
      {
        test: /\.vue$/i,
        loader: 'vue-loader'               // 配置 loader 为自己的 loader 名字
      },
    ],
  },
  resolve: {
    alias: {
      '@/': path.resolve(__dirname, './src/'),
    },
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
  plugins: [
    new html({                                          // 配置html模板和插入script的位置
      template: path.join(__dirname, './index.html'),
      inject: 'body',
    }),
  ],
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, './loaders')], // 自定义 loader 的查找路径
  },
};

module.exports = config;

App.vue:

App.vue

vue

<template>
  <div>{{ msg }}</div>
</template>

<script setup>
import { ref } from 'vue';

const msg = ref('Hello World');
</script>


<style scoped>
div {
  height: 100vh;
}
</style>

main.js:

main.js

javascript

import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.mount('#app');

index.html:

index.html

html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Demo</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

编译 SFC

在这里 loader 是用来处理 vue 的 sfc 语法的, 所以这里将传入的源代码进行编译, 并且返回编译后的代码.

编译 sfc 使用 vue 官方提供的 @vue/compiler-sfc.

vue-loader

javascript

const complierSFC = require('@vue/compiler-sfc');
/**
 * @type { import('webpack').LoaderDefinitionFunction }
 */
function loader(source) {
  console.log('loader');
  const { descriptor } = complierSFC.parse(source);
  // ....
}

这样会解析出 sfc 的各个部分, template, script, style.

以下是解析出来的 descriptor 的结构声明:

compiler-sfc.d.ts

typescript

interface SFCDescriptor {
  filename: string;
  source: string;
  template: SFCTemplateBlock | null;
  script: SFCScriptBlock | null;
  scriptSetup: SFCScriptBlock | null;
  styles: SFCStyleBlock[];
  customBlocks: SFCBlock[];
  cssVars: string[];
  /**
   * whether the SFC uses :slotted() modifier.
   * this is used as a compiler optimization hint.
   */
  slotted: boolean;
  /**
   * compare with an existing descriptor to determine whether HMR should perform
   * a reload vs. re-render.
   *
   * Note: this comparison assumes the prev/next script are already identical,
   * and only checks the special case where <script setup lang="ts"> unused import
   * pruning result changes due to template changes.
   */
  shouldForceReload: (prevImports: Record<string, ImportBinding>) => boolean;
}

其中:

  • filename: 文件名称, 传入进去的
  • source: 源代码, 传入进去的
  • template: 模板部分代码和 AST
  • script: script 部分代码, 尚未解析为 AST
  • scriptSetup: script setup 部分代码, 尚未解析为 AST
  • styles: 各类 css 的代码块, 不包含 AST
  • customBlocks: 自定义代码块的代码
  • cssVars: v-bind css 语法糖所涉及的变量

此处简化版本, 所以只需要 styles, template, script 或 scriptSetup 三种类型的代码块即可. css 不使用预处理器, 只有一个 style 块, 不对自定义代码块做任何处理.

额外内容
如果同时写 setup 和 非 setup 代码, 则无论书写顺序, setup 相同字段会覆盖非 setup 相同字段, 一般情况下 setup 会被编译成 setup 函数, 覆盖 非 setup 语法的 setup 结果

直接通过 parse 出来的结构肯定是不能用的, 因此还需要做一些处理, 比如将 template 的 ast 转换成 render 函数, 将 script 的代码转换成 vue js 代码, 将 style 的代码转换成可插入 css 代码.

这些都在 @vue/compiler-sfc 中有对应的 API.

vue-loader

javascript

const crypto = require('node:crypto');
/**
 * @type { import('webpack').LoaderDefinitionFunction }
 */
function loader(source) {
  // ...
  const filename = this.utils
  .contextify(
    this.context,
    this.utils.absolutify(this.context, this.resourcePath)
  )
  .replace(that.resourceQuery, '');
  const id = crypto.createHash('md5').update(filename).digest('hex');
  const {
    content: scriptCode,
    bindings,
  } = complierSFC.compileScript(descriptor, {
    id,
    filename,
  });
  const { code: render } = complierSFC.compileTemplate({
    ast: descriptor.template.ast,
    id,
    filename,
    compilerOptions: {
      bindingMetadata: bindings,
    },
  });
  let styleCode = '';
  if (descriptor.styles.length) {
    const { code } = complierSFC.compileStyle({
      id,
      source: descriptor.styles[0].content,
      filename,
      scoped: descriptor.styles[0].scoped,
    });
    styleCode = code;
  }
  //  ...
}
// ...

这里的 filename 文件名是通过 loader context 获取的. id 使用文件名生成一个, 当然, 源码中不是这样的.

bindings

json

{
  "ref": "setup-const",
  "msg": "setup-ref"
}

其中在 compileTemplate 传入的 bindings 是用来对模板中使用变量的来源的存储, 所以这里需要传入.

id 目前所知的作用是用于对样式的影响, 带有 scoped 属性的 style 会对其中的选择器添加这个 id 避免样式污染.

vue-id

data-v 后面的就是这里传入的 id.

额外内容
如果父级组件对子组件根赋予类名样式, 则一般会覆盖子组件同优先级样式, 这是因为样式顺序的问题, 而并非 vue 的原因

其中 compileScript, compileTemplate, compileStyle 这些方法的调用类型声明有点长, 所以这里就不贴出来了.

几个方法的类型声明:

compileScript compileStyle compileTemplate

这里只是做一个简单的实现, 所以只需要传入几个参数就够用.

输出处理

三个方法编译出来了对应的代码, 但是这些代码还无法直接交给 webpack 处理.

script 的结果:

compile-script

javascript

import { ref } from "vue";

export default {
  __name: "App",
  setup(__props, { expose: __expose }) {
    __expose();

    const msg = ref("Hello World");

    const __returned__ = { msg, ref };
    Object.defineProperty(__returned__, "__isScriptSetup", {
      enumerable: false,
      value: true,
    });
    return __returned__;
  },
};

template 的结果:

compile-template

javascript

import {
  createElementBlock as _createElementBlock,
  openBlock as _openBlock,
  toDisplayString as _toDisplayString,
} from "vue";

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, _toDisplayString($setup.msg), 1 /* TEXT */)
  );
}

style 的结果:

compile-style

css

div[data-v-df4a7b6d947e9bab69b750600a68fc3b] {
  height: 100v;
}

其中 style 的处理稍微麻烦些, 之后再说, 先处理 render 和 setup.

可以看到, vue 编译出来的结果是导出的形式, 因为无法直接返回, 需要按照 vue 的方式做一个出来:

compiler-sfc

result

javascript

// main script
import script from '/project/foo.vue?vue&type=script'
// template compiled to render function
import { render } from '/project/foo.vue?vue&type=template&id=xxxxxx'
// css
import '/project/foo.vue?vue&type=style&index=0&id=xxxxxx'

// attach render function to script
script.render = render

// attach additional metadata
// some of these should be dev only
script.__file = 'example.vue'
script.__scopeId = 'xxxxxx'

// additional tooling-specific HMR handling code
// using __VUE_HMR_API__ global

export default script

通过返回引入的形式, 并且将 render 赋予 setup 的导出中. 除了这种方式, 也可以使用 AST 处理, 但是那样会非常麻烦, 所以这里也按照这种方式处理.

vue-loader

javascript

const map = new Map(); // 用于缓存处理结果
function loader(source) {
  console.log('loader');
  const filename = this.utils
    .contextify(
      this.context,
      this.utils.absolutify(this.context, this.resourcePath)
    )
    .replace(this.resourceQuery, '');
  const query = new URLSearchParams(this.resourceQuery); // 取出 url 查询字符串
  const name = this.resourcePath;
  const type = query.get('type');
  const id = crypto.createHash('md5').update(filename).digest('hex');
  if (type) {
    return map.get(name)[type];
  }
  //  ....
  map.set(name, {
    template: templateCode,
    script: scriptCode,
    style: styleCode,
  }); // 缓存处理结果
  return `
    import script from '${filename}?type=script';
    import { render } from '${filename}?type=template';
    import '${filename}?type=style';

    script.render = render;
    script.__file = ${JSON.stringify(filename)};
    script.__scopeId = ${JSON.stringify(`data-v-${id}`)};
    export default script;
  `
}
//  ...

这里通过 Map 缓存各个部分的处理结果, 并返回通过 import 的形式引入的代码. 当然, 还是简化形式.

这样目前算是一段落, 但是目前导出的 ESM 代码是 webpack 无法识别的, 因此还需要进一步处理. 在 VueLoaderPlugin 中为 vue-loader 之后的执行添加了 babel-loader, css-loader 等处理 js, css 的已配置的处理其他类型文件的 loader,

2024-10-30: 注意
由于之前的错误, babel 配置可以选择性忽略, 在 VueLoaderPlugin 中是复制了已经配置过的 js, css 配置, 添加至 vue-loader 前面, 用于处理相关类型代码/文件.

这里为了简化, 可以选择直接在当前 loader 使用 babel 进行处理, 也可以手动在 webpack 添加 babel.

webpack.config.js

javascript

const path = require('node:path');
const html = require('html-webpack-plugin');

/**
 * @type {import('webpack').Configuration}
 */
const config = {
  mode: 'development',
  entry: path.resolve(__dirname, './src/main.js'), // 入口文件
  output: {
    path: path.resolve(__dirname, 'dist'), // 输出路径
    clean: true,                           // 构建前清理输出目录
    assetModuleFilename: '[name][ext]',    // 静态资源文件名
    chunkFilename: '[name].js',            // 块文件名
    publicPath: './',                      // 静态资源引用路径
  },
  target: 'web',                           // 浏览器环境
  module: {
    rules: [
      {
        test: /\.vue$/i,
        loader: 'vue-loader',
        // 修改 loader 配置, 在 vue-loader 前添加 babel-loader
        use: ['babel-loader',  'vue-loader']    
      },
    ],
  },
  resolve: {
    alias: {
      '@/': path.resolve(__dirname, './src/'),
    },
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
  plugins: [
    new html({                                          // 配置html模板和插入script的位置
      template: path.join(__dirname, './index.html'),
      inject: 'body',
    }),
  ],
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, './loaders')], // 自定义 loader 的查找路径
  },
};

module.exports = config;

这里我们直接使用 babel 处理, 不修改 webpack 配置.

注意
不推荐在 loader 中存储数据, 同时 loader 功能应该单一化, 这里仅为简化使用

目前通过 babel 转换后的 loader 部分:

vue-loader

javascript

const complierSFC = require('@vue/compiler-sfc');
const babel = require('@babel/core');
const crypto = require('node:crypto');
const map = new Map();
function loader(source) {
  console.log('loader');
  const filename = this.utils
    .contextify(
      this.context,
      this.utils.absolutify(this.context, this.resourcePath)
    )
    .replace(this.resourceQuery, '');
  const query = new URLSearchParams(this.resourceQuery);
  const name = this.resourcePath;
  const type = query.get('type');
  const id = crypto.createHash('md5').update(filename).digest('hex');
  if (type) {
    return map.get(name)[type];
  }
  const { descriptor } = complierSFC.parse(source, {
    filename,
  });
  const {
    content: scriptCode,
    bindings,
  } = complierSFC.compileScript(descriptor, {
    id,
    filename,
  });
  const { code: templateCode } = complierSFC.compileTemplate({
    ast: descriptor.template.ast,
    id,
    filename,
    compilerOptions: {
      bindingMetadata: bindings,
    },
  });
  let styleCode = '';
  if (descriptor.styles.length) {
    const { code } = complierSFC.compileStyle({
      id,
      source: descriptor.styles[0].content,
      filename,
      scoped: descriptor.styles[0].scoped,
    });
    styleCode = code;
  }
  const templateTransform = babel.transformSync(templateCode, {
    babelrc: false,
    presets: ['@babel/preset-env'],
  });
  const scriptTransform = babel.transformSync(scriptCode, {
    babelrc: false,
    presets: ['@babel/preset-env'],
  });
  map.set(name, {
    template: templateTransform.code,
    script: scriptTransform.code,
    style: styleCode,
  });
  return babel.transformSync(
    `
    import script from '${filename}?type=script';
    import { render } from '${filename}?type=template';
    import '${filename}?type=style';

    script.render = render;
    script.__file = ${JSON.stringify(filename)};
    script.__scopeId = ${JSON.stringify(`data-v-${id}`)};
    export default script;
  `,
    {
      babelrc: false,
      presets: ['@babel/preset-env'],
    }
  ).code;
}

这里关于 script 和 template 部分的处理就完成了.

处理 style

相对于 js 代码, css 无法直接被 webpack 处理, 因此需要通过其他 loader 或者插件来处理. 在官方的 vue-loader 中, 是直接复制了相关类型的 webpack 配置, 这里我们采用简化的方式处理.

在上面处理的代码中, 引入 css 的方式是通过 import 'xxx.vue?type=style' 的形式, 但是 webpack 无法识别 css 代码, 需要 css-loader 处理. 这里就用到了前面提到的 pitch.

通过在 pitch 里返回 require 引入代码, 让 webpack 使用 css-loader 对 css 内容进行处理.

pitch 方法如下:

vue-loader

javascript

// ...
loader.pitch = function (resourcePath) {
  console.log('pitch', resourcePath);
  const that = this;
  const filename = that.utils
    .contextify(
      that.context,
      that.utils.absolutify(that.context, that.resourcePath)
    )
    .replace(that.resourceQuery, '');
  const query = new URLSearchParams(that.resourceQuery);
  const type = query.get('type');
  if (type === 'styleReq') {
    return `require('!style-loader!css-loader!vue-loader!${filename}?type=style')
    `;
  }
};
// ...

这里我们修改请求, 让 webpack 通过 vue-loader 获取到 css 源代码, 然后通过 css-loader -> style-loader 的步骤对 css 进行处理.

vue-loader

javascript

// ...
function loader(source) {
  console.log('loader');
  // ....
  return babel.transformSync(
    `
    import script from '${filename}?type=script';
    import { render } from '${filename}?type=template';
    import '${filename}?type=style';
    import '${filename}?type=styleReq';

    script.render = render;
    script.__file = ${JSON.stringify(filename)};
    script.__scopeId = ${JSON.stringify(`data-v-${id}`)};
    export default script;
  `,
    {
      babelrc: false,
      presets: ['@babel/preset-env'],
    }
  ).code;
}
// ...

修改请求路径, 让 pitch 能够判断到类型, 并拦截给 css-loader 等处理器.

完整代码

vue-loader.js

javascript

const complierSFC = require('@vue/compiler-sfc');
const babel = require('@babel/core');
const crypto = require('node:crypto');

const map = new Map();
/**
 * @type { import('webpack').LoaderDefinitionFunction }
 */
function loader(source) {
  console.log('loader');
  const filename = this.utils
    .contextify(
      this.context,
      this.utils.absolutify(this.context, this.resourcePath)
    )
    .replace(this.resourceQuery, '');
  const query = new URLSearchParams(this.resourceQuery);
  const name = this.resourcePath;
  const type = query.get('type');
  const id = crypto.createHash('md5').update(filename).digest('hex');
  if (type) {
    return map.get(name)[type];
  }
  const { descriptor } = complierSFC.parse(source, {
    filename,
  });
  const {
    content: scriptCode,
    bindings,
  } = complierSFC.compileScript(descriptor, {
    id,
    filename,
  });
  const { code: templateCode } = complierSFC.compileTemplate({
    ast: descriptor.template.ast,
    id,
    filename,
    compilerOptions: {
      bindingMetadata: bindings,
    },
  });
  let styleCode = '';
  if (descriptor.styles.length) {
    const { code } = complierSFC.compileStyle({
      id,
      source: descriptor.styles[0].content,
      filename,
      scoped: descriptor.styles[0].scoped,
    });
    styleCode = code;
  }
  const templateTransform = babel.transformSync(templateCode, {
    babelrc: false,
    presets: ['@babel/preset-env'],
  });
  const scriptTransform = babel.transformSync(scriptCode, {
    babelrc: false,
    presets: ['@babel/preset-env'],
  });
  map.set(name, {
    template: templateTransform.code,
    script: scriptTransform.code,
    style: styleCode,
  });
  return babel.transformSync(
    `
    import script from '${filename}?type=script';
    import { render } from '${filename}?type=template';
    import '${filename}?type=styleReq';

    script.render = render;
    script.__file = ${JSON.stringify(filename)};
    script.__scopeId = ${JSON.stringify(`data-v-${id}`)};
    export default script;
  `,
    {
      babelrc: false,
      presets: ['@babel/preset-env'],
    }
  ).code;
}

loader.pitch = function (resourcePath) {
  console.log('pitch', resourcePath);
  const that = this;
  const filename = that.utils
    .contextify(
      that.context,
      that.utils.absolutify(that.context, that.resourcePath)
    )
    .replace(that.resourceQuery, '');
  const query = new URLSearchParams(that.resourceQuery);
  const type = query.get('type');
  if (type === 'styleReq') {
    return `require('!style-loader!css-loader!vue-loader!${filename}?type=style')
    `;
  }
};

module.exports = loader;

一个简易的 vue-loader 就这样完成了.

当然, 在官方的 loader 中会有更多的边界处理之类的东西, 同时缓存的也并不是结果代码, 而是 descriptor. 处理过程并非在一个 loader 中, 而是通过 VueLoaderPlugin 插入了更多内容.

总结

本文介绍了如何从零开始实现一个简易的 Vue 单文件组件 (SFC) 的 webpack loader。通过解析和编译 SFC 的各个部分 (template、script、style), 并使用 Babel 处理生成的 JavaScript 代码,最终实现了对 Vue 组件的加载和处理。 在这个过程中学习了关于 vue 编译的一些内容, 以及对 webpack 的浅浅了解.

© 9999 Vivia Name

Powered by Nextjs & Theme Vivia

主题完全模仿 Vivia 主题, 有些许差异, 及使用nextjs乱写

Theme Toggle