lastModified: 2024-10-30
2024-10-30: 包含部分错误内容
前言
九月份提出辞职后, 心里多少有点放松, 到十月份离职期间几乎玩了一整个月. 再加上手指关节有点问题, 所以这段时间就很少接触电脑. 十月份中从沈阳回到了老家, 准备换个南方点的城市尝试找份工资, 为自己的履历增加点新鲜内容.
不过这快两个月的空闲时间, 啥都没干, 让我对之后的事情有点没底, 所以这两天就打算把之前一直想写的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 块, 不对自定义代码块做任何处理.
额外内容
直接通过 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 避免样式污染.
data-v 后面的就是这里传入的 id.
额外内容
其中 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 的方式做一个出来:
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: 注意
这里为了简化, 可以选择直接在当前 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 配置.
注意
目前通过 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 的浅浅了解.