最近要给项目用到的组件库里的所有组件都要加一个 prop ,总共 126 个组件,如果是手动加,emm,也不是不可以,但就是有点呆,更何况如果后续有变动的话,又得重新来一遍。于是就想,如何用一种全局的配置,去统一给所有组件加 prop (这里没有使用
Vue.mixin
去全局混入,下文会作解释)。最后打算开发一个 Webpack Loader 去实现这个功能,刚好之前也没写过 Loader ,借这个机会试一试,顺便了解一下 Loader 的运行机制,如果能实现出来,想想就很酷 😉。(文末有仓库地址)
为什么没有用 Vue.mixin?
首先,先来讲一下 Vue.mixin
全局混入的机制。一个使用 Vue2 搭建的项目,我们可以通过 Vue.prototype.someProp = 'hello'
的方式,在 Vue 的原型上挂载一个全局属性 someProp ,这样项目下所有的 Vue 实例都可以通过 this.someProp
的方式访问到。简单点说,就是每个 Vue 实例都能访问到原型上的属性。
也可以通过 Vue.mixin
混入一个全局配置,例如像下面这样:
Vue.mixin({
data() {
return {
someProp: "hello"
}
}
})
不过会有一个问题,它影响每一个之后创建的 Vue 实例,就是当前项目下所有的 Vue 实例都会被混入。而我们只需要对组件库中的所有组件混入就行,这种做法的影响范围有些广,有点不可取。同时,组件库和原项目之间耦合性又增加了,不建议。
为什么要用 Loader 去实现?
因为之前写一个 Prettier Plugin ,最开始也打算故技重施,再用 Prettier 写一个插件,对组件库中的所有 Vue 文件,添加一个 Mixin 。这种方案其实就是代替人工去给每一个 Vue 组件手动添加 Mixin 了。如果后续有更改,或者要把这个 Mixin 丢掉,有得重新写一个 Plugin ,emm,已经感到有点麻烦了。如果能通过外部插入的方式,添加 Mixin ,可随时注入或撤销,同时不对原文件做更改,那最好不过了。
Webpack Loader 了 ~ 解 ~ 一 ~ 下 ~~~(画外音)
关于 Loader ,Webpack 官网是这样介绍的:
Webpack enables use of loaders to preprocess files. This allows you to bundle any static resource way beyond JavaScript. You can easily write your own loaders using Node.js.
机翻:Webpack 允许使用加载器对文件进行预处理。这允许您以 JavaScript 之外的方式捆绑任何静态资源。你可以很容易地使用 Node.js 编写自己的加载器。
因为 Webpack 只能解析 JS 和 JSON 类型的文件,对于其他类型的文件都需要安装一些 Loader 去处理,比如,vue-loader
、style-loader
、less-loader
等等。
组件库在进行打包时,也需要使用 vue-loader
去处理 Vue 文件,就像下面这样:
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.vue$/,
use: "vue-loader"
},
{
test: /\.less$/,
use: ["vue-style-loader", "css-loader", "less-loader"]
}
]
}
}
可以使用多个 Loader 去处理,例如,上面的配置中,对于 .less
文件,使用了三个 Loader,并且按照从右到左的顺序依次执行。也就是,先由 less-loader
处理,将处理后的结果,传给 css-loader
,css-loader
处理完后,再交给 vue-style-loader
处理。
大致的实现思路就是:在 vue-loader
处理前,把 Mixin 注入到 Vue 文件里,然后再交给 vue-loader
处理,只需要保证在注入 Mixin 后的 Vue 文件的合法性即可。
最后使用 Loader 后的 Webpack 配置大致如下:
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.vue$/,
use: [
"vue-loader",
{
loader: VueMixinsLoader,
options: {
// TODO
}
}
]
}
]
}
}
通过 Loader 去注入,在不改变原文件的前提下,可操作性更强了!
Loader’s options 配置
身为一个 Loader ,那就少不了 options 配置,这里的 options 以一个对象的形式提供。为了更接近 Mixin 的写法,并且方便从外部引入,提供了有两种可选方式的配置:
1. 引入外部资源
对于比较复杂的 Mixin 可能需要单独封装在一个公共模块里,然后通过 import
的方式引入。比如,需要引入 src/utils/tools.js
这个模块。可以写成下面这样:
配置
{
loader: VueMixinsLoader,
options: {
tools: "src/utils/tools.js"
}
}
原文件
import A from "a.js"
export default {
// ...
}
处理后
+ import toolsMixin from "src/utils/tools.js"
import A from "a.js"
export default {
// ...
+ mixins: [toolsMixin]
}
提供的资源路径会原封不动地注入到 Vue 文件里,也就是需要保证 在不同的 Vue 文件中使用这个路径引入时,都能引入到这个资源。为了保证每个 Vue 文件都能正常地引入这个资源,建议使用绝对路径,或者使用 alias
别名。
{
loader: VueMixinsLoader,
options: {
tools: path.resolve("src/utils/tools.js"), // 使用 path.resolve 将路径转为绝对路径
tools2: "@/utils/tools.js", // 使用 @ 别名,@ 为 src 目录的别名
}
}
你可能会有疑问:加一句 import 和 mixins 就能注入了?其实
vue-loader
在解析时,也是拿到整个 Vue 文件代码的字符串,然后再去做解析的。单文件组件只是 Vue 提供的一种近似于原生 HTML 的写法,便于开发者开发。底层处理的时候,还是把整个文件当作一个字符串,然后去解析,没有那么玄乎(狗头保命)。
2. custom 属性
对于一些简单的 Mixin ,不需要以外部资源的形式引入,那么就可以直接提供一个对象,都写在 custom 这个属性下。
后续测试时,发现了一个问题:webpack 在处理 options 时,会将其转为 JSON 格式,也就意味着,对于一些属性为函数的,就会丢失。研究了一种解决方案,vue-mixins-loader 提供了一个
stringify
方法,需要对这个custom
对象进行包裹,将其转为一个字符串,保证 JSON 序列化时不会丢失属性。
配置
{
loader: VueMixinsLoader,
options: {
custom: VueMixinsLoader.stringify({
props: {
message: {
type: String,
default: 'Hello World'
}
},
created() {
console.log('this is created hook')
},
mounted() {
console.log('this is mounted hook')
}
})
}
}
写法和 Mixin 完全一样。最后处理的时候,会把整个对象赋值给 customMixin
这个变量,然后注入到 mixins 里。
原文件
import A from "a.js"
export default {
// ...
}
处理后
import A from "a.js"
+ const customMixin = {
+ props: {
+ message: {
+ type: String,
+ default: "Hello World"
+ }
+ },
+ created() {
+ console.log("this is created hook")
+ },
+ mounted() {
+ console.log("this is mounted hook")
+ }
+ }
export default {
// ...
+ mixins: [customMixin]
}
混合使用
两种方式可以混合使用,并且如果原 Vue 文件中也有引入自己的 Mixin ,则会把它们合并。
配置
{
loader: VueMixinsLoader,
options: {
tools: "@/utils/tools.js",
custom: VueMixinsLoader.stringify({
props: {
message: {
type: String,
default: "Hello World"
}
}
})
}
}
原文件
import A from "a.js"
import BMixin from "b.js"
export default {
// ...
mixins: [BMixin]
}
处理后
+ import toolsMixin from "@/utils/tools.js"
import A from "a.js"
import BMixin from "b.js"
+ const customMixin = {
+ props: {
+ message: {
+ type: String,
+ default: "Hello World"
+ }
+ }
+ }
export default {
// ...
- mixins: [BMixin]
+ mixins: [BMixin, toolsMixin, customMixin]
}
实现原理
看到这里,你是不是很好奇,上面的操作是如何实现的。细心的你想必也发现了,文章的标签里有 AST 和 Babel ,到现在还没有提及,是时候登场啦!
得益于 Babel 的强大,可以将任意一段 JS 代码,解析成 AST 。这也就使得我们可以对生成的 AST 做一些操作,比如,格式化代码、删除注释等等,这次的 VueMixinLoader 也是如此。
AST 在线生成网站:https://astexplorer.net
1. 解析 options 配置
关于 options 中的配置项,只对值为 String 类型的属性和 custom 属性做处理,其他的忽略就好,不做任何处理。
值为 String 类型的,都将该值作为一个资源路径,属性名作为变量名前缀,后缀为 Mixin
,然后生成 ImportMixin
语句:import [属性名]Mixin from [值]
。
例如,对于下面这种 options 配置:
const options = {
utils: "@/utils",
libs: "@/libs"
}
生成的 ImportMixin
语句为:
import utilsMixin from "@/utils"
import libsMixin from "@/libs"
还需要记录一下名称 importMixinNames = ["utilsMixin", "libsMixin"]
,后面还需要用到。
而 custom 属性,要把它的值对象转成一个字符串,注意这一步不是调用 JSON.stringify
就可以了的。比如,对于下面这个:
const custom = {
props: {
message: {
type: String,
default: "Hello World"
}
}
}
JSON.stringify(custom) // { "props": { "message": { "default" : "Hello World" } } }
JSON.stringify
不能处理函数、undefined
、Symbol
和RegExp
。
还好有第三方工具专门实现了这个功能:serialize-javascript。不过它不支持值为原生构造函数类型的,像是,Number ,String ,Array ,Object 等等。并且还有 BUG,对于普通函数内部使用了箭头函数的,序列化后的结果就会有问题。
const serialize = require("serialize-javascript")
const custom = {
methods: {
fn() {
const f = () => {}
}
}
}
serialize(custom)
/** 序列化的结果
{
"methods": {
"fn": fn() {
const f = () => {}
}
}
}
*/
翻了翻源码,关于是否是箭头函数的判断写得有点问题,源码是这样写的(省去了一些代码):
var IS_ARROW_FUNCTION = /.*?=>.*?/
function serializeFunc(fn) {
var serializedFn = fn.toString()
// arrow functions, example: arg1 => arg1+5
if (IS_ARROW_FUNCTION.test(serializedFn)) {
return serializedFn
}
}
emm,这个正则,着实有点粗糙了哈。问了问 ChatGPT,这个正则该怎么写,它给的答案是下面这样:
var IS_ARROW_FUNCTION = /^(\([\w\s,]*\)|[\w\s]*)\s*=>/
测了测,确实没啥问题,先把 node_modules 里的改成这样,等有空了提个 PR,看看会不会被合并 🤭。
仔细想想,custom 里也不会写一些比较复杂的逻辑,如果很复杂,建议还是通过外部资源引入的方式。这样的话,原箭头函数的判断就已经满足了,不需要额外修改源代码。但是,写都写了,岂有不用的道理(拽)。
下面接着解决原生构造函数序列化的问题,原生构造函数调用 toString()
方法后,得到的都是下面这些值:
const PropTypeStr = [
"function String() { [native code] }",
"function Number() { [native code] }",
"function Boolean() { [native code] }",
"function Array() { [native code] }",
"function Object() { [native code] }",
"function Date() { [native code] }",
"function Function() { [native code] }",
"function Symbol() { [native code] }"
]
也就意味着,如果当前值类型为 Function
,并且调用 toString()
方法后的结果在上述的列表中,则说明当前函数为原生构造函数,需要特殊处理。如果是数组,并且数组里的每一项也满足这两个条件,则也需要特殊处理。主要针对的就是 props 里的某个属性,可以设置多个 type 的场景。
const custom = {
props: {
prop1: {
type: [String, Number],
default: "Hello World"
},
prop2: {
type: Object,
default: () => ({})
}
}
}
下一步,如何特殊处理?
首先,特殊处理的这一步操作,要在序列化之前,从而保证整体能正常序列化。也即需要把原生构造函数转换成可被序列化的格式,简单点转成字符串就行。不过,这不是简简单单的字符串,需要做个标记,保证能由字符串还能转为原来构造函数的形式。
先用一些特殊标记把原生构造函数包裹起来。以上述的 custom 为例,先转成下面这种格式:
custom = {
props: {
prop1: {
type: "__ConstructorFn([String, Number])",
default: "Hello World"
},
prop2: {
type: "__ConstructorFn(Object)",
default: () => ({})
}
}
}
序列化后的结果如下:
customStr = `{
"props": {
"prop1": {
"type": "__ConstructorFn([String, Number])",
"default": "Hello World"
},
"prop2": {
"type": "__ConstructorFn(Object)",
"default": () => ({})
}
}
}`
移除特殊标记,一个正则搞定:
const removeConstructorFnTag = str => {
return str.replace(/['"]__ConstructorFn\(([^)]+)\)['"]/g, "$1")
}
customStr = removeConstructorFnTag(customStr)
/**
{
"props": {
"prop1": {
"type": [String, Number],
"default": "Hello World"
},
"prop2": {
"type": Object,
"default": () => ({})
}
}
}
*/
生成 customMixin
语句:
const customMixin = {
"props": {
"prop1": {
"type": [String, Number],
"default": "Hello World"
},
"prop2": {
"type": Object,
"default": () => ({})
}
}
}
至此,得到了 importMixin
语句和 customMixin
语句,后续需要插入到 Vue 的 script
标签内。
import utilsMixin from "@/utils"
import libsMixin from "@/libs"
const customMixin = {
"props": {
"prop1": {
"type": [String, Number],
"default": "Hello World"
},
"prop2": {
"type": Object,
"default": () => ({})
}
}
}
2. 解析 script 标签
这里使用 vue-template-compiler
去解析 Vue 文件。
const compiler = require("vue-template-compiler")
const source = `<template>
<p>{{ greeting }} World!</p>
</template>
<script>
export default {
data () {
return {
greeting: "Hello"
};
}
};
</script>
<style scoped>
p {
font-size: 2em;
text-align: center;
}
</style>
`
const { script } = compiler.parseComponent(source)
// script 内容的开始和结束位置
const { start, end } = script
// script 标签里的内容
const scriptContent = script.content
其中,source.slice(start, end) == scriptContent == script.content
。
3. 生成新的代码
首先介绍一下 Babel 三步走:parse
、traverse
、generate
。
parse
:对应功能模块@babel/parser
,解析 JavaScript 代码,并将其转换为 AST (Abstract Syntax Tree)抽象语法树。traverse
:对应功能模块@babel/traverse
,遍历 AST 抽象语法树,并对其进行修改或分析。generate
:对应功能模块@babel/generator
,将 AST 抽象语法树转换为 JavaScript 代码的字符串形式。
通俗点讲,假如说你有一个玩偶,电池没电了,需要更换电池。但是更换电池,需要用螺丝刀把它拆开,才能更换。而
parse
就相当于能把整个玩偶拆成各种零部件,你只需要把旧电池换成新电池(这一步就是traverse
),最后再交给generate
重新组装成玩偶。不需要用螺丝刀拆开,然后再给合上了,简单了好多。如果你还想把玩偶的眼睛,由小黄灯换成小红灯,也是只需要把黄灯部件换成红灯部件即可,完全不需要自己动手拆。
先来看一段 JS 代码生成 AST 后的结果:
program.body
是个数组,有两个 ImportDeclaration
类型的节点和一个 ExportDefaultDeclaration
类型的节点,分别对应两个 import
语句和一个 export default
语句。
比如,要清空所有 import
节点,就可以用下面的方式实现。
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const generate = require("@babel/generator").default
// scriptContent 来源于第二步生成的
// 因为是对 Vue 文件解析,需要配置 { sourceType: "module" }
// 如果代码里有用到 JSX 语法,需要配置 { plugins: ["jsx"] }
const scriptAst = parser.parse(scriptContent, { plugins: ["jsx"], sourceType: "module" })
traverse(scriptAst, {
// 遍历 import 节点
ImportDeclaration(path) {
// path.node 是当前节点
scriptAst.program.body = scriptAst.program.body.filter(node => node !== path.node)
}
})
// 生成代码字符串
const newScript = generate(scriptAst).code
如果,再需要向 mixins
中注入新的内容呢。
先看一下 mixins
对应的 AST 长啥样。
value.elements
是个数组,数组的每个元素都是一个对象,里面存了关于 mixins
的值信息。照葫芦画瓢,如果需要新增一个 customMixin
,就可以把这个 elements
数组改成:
elements = [
{
type: "Identifier",
name: "TestMixin"
},
+ {
+ type: "Identifier",
+ name: "customMixin"
+ }
]
具体实现代码如下:
traverse(scriptAst, {
// 遍历 export default 节点
ExportDefaultDeclaration(path) {
const properties = path.node.declaration.properties
// 先找到 mixins 节点
const mixins = properties.find(property => property.key.name === "mixins")
const customMixin = {
type: "Identifier",
name: "customMixin"
}
// 插入 customMixin
mixins.push(customMixin)
}
})
回顾一下,在第一步中,我们拿到了 importMixin
和 customMixin
,这两个需要添加到 script
标签里,importMixinNames
是需要注入的名称列表。第二步里,我们解析得到了 script
中的内容。刚才,我们又向 mixins
中插入了新的 mixin
。有了这些后,我们就可以生成一份新的 Vue 文件字符串。
const compiler = require("vue-template-compiler")
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const generate = require("@babel/generator").default
// source 来源于第二步提供的 Vue 文件字符串
const { script } = compiler.parseComponent(source)
const { start, end } = script
// 在原 script 的头部添加 importMixin 和 customMixin
const scriptContent = importMixin + "\n" + customMixin + "\n" + script.content
const scriptAst = parser.parse(scriptContent, { plugins: ["jsx"], sourceType: "module" })
traverse(scriptAst, {
ImportDeclaration(path) {
scriptAst.program.body = scriptAst.program.body.filter(node => node !== path.node)
},
ExportDefaultDeclaration(path) {
const properties = path.node.declaration.properties
const mixins = properties.find(property => property.key.name === "mixins")
const newMixins = [...importMixinNames, "customMixin"].map(name => ({
type: "Identifier",
name
}))
mixins.push(...newMixins)
}
})
const newScript = generate(scriptAst).code
// 使用新的 script 内容
const newContent = source.slice(0, start) + `\n${newScript}\n` + source.slice(end)
4. 封装成 Loader
Loader 开发参考:
Loader 其实就是一个函数,只不过大部分都写在一个单独的 JS 文件里,然后默认导出。
module.exports = function (source) {
return source + "Hello World"
}
上面就是一个简单的 Loader ,往文件内容里追加一个 "Hello World"
字符串。
除了在上一步中,生成新代码的逻辑之外,还需要 loader-utils
用来获取 Loader 的 options 。
const { getOptions } = require("loader-utils")
module.exports = function (source) {
const options = getOptions(this)
// ... 解析 options,生产新代码 newSource
return newSource
}
在配置文件中使用本地 Loader 。
// webpack.config.js
const path = require("path")
const VueMixinsLoader = path.resolve("plugins/VueMixinsLoader/index.js")
module.exports = {
// ...
module: {
rules: [
{
test: /\.vue$/,
use: [
"vue-loader",
{
loader: VueMixinsLoader,
options: {
tools: resolve("./test/VueMixinsLoader/src/mixins/mixin.js"),
tools2: resolve("./test/VueMixinsLoader/src/mixins/mixin2.js"),
tools3: "@test/VueMixinsLoader/src/mixins/mixin3.js",
custom: VueMixinsLoader.stringify({
props: {
block: {
type: Object,
default: () => ({})
}
}
})
}
}
]
}
]
}
}
细节部分
大致的实现思路就是上述内容,其实还有一些细节问题没有谈到,这里简单说一下:
importMixin
和customMixin
并没有直接插入在script
的头部,而是都先转成 AST(importMixinAST
和customMixinAST
)。importMixinAST
插入在了源 Vue 文件import
语句的前面,customMixinAST
插入在了import
语句的下面(为了保证代码风格,这是我最后的倔强)。- 源 Vue 组件里,可能会没有
mixins
这个配置项,需要生成一个mixins
的 AST,然后插入到ExportDefaultDeclaration
中。 - 使用 webpack 打包时,如果使用了
cache-loader
会导致,修改 options 后,重新打包,配置不会生效。需要关闭cache-loader
,并开启cache: false
,确保每次打包配置都能生效。
从确定要开发这个 Loader 到功能完善,差不多弄了两天半的时间,写这篇文章写了两天,emm。
当我兴致勃勃准备发布到 npm 社区的时候,发现 vue-mixin-loader
这个名字被人占用了,emm。于是就改成了 vue-mixins-loader
,突然发现这个名字更符合,好巧不巧,嘻嘻嘻。
总体来看,结果还蛮不错的,又可以往简历里写新花样了 🤭!