发布时间:2025-11-04 19:12:19 来源:码上建站 作者:应用开发

本文转载自微信公众号「码农小余」,深入作者Jouryjc 。浅出转载本文请联系码农小余公众号。定义
本文大纲:
通过 vue-i18n 的深入 了解 customBlocks 和基本配置; 从源码层面了解 vue-loader 对 customBlocks 的处理vue-i18n[1] 是 Vue 的国际化插件。如果使用 SFC 的浅出方式写组件的话,可以在 .vue 文件中定义 块 ,定义然后在块内写入对应的深入词条。这个 i18n 标签就是浅出 customBlocks。举个例子:
<template> <p>{{ $t(hello) }}</p> </template> <script> // App.vue export default { name: App } </script> <i18n locale="en"> { "hello": "hello,定义 world!!!!" } </i18n> <i18n locale="ja"> { "hello": "こんにちは、世界!深入" } </i18n> // main.js import Vue from vue import VueI18n from vue-i18n import App from ./App.vue Vue.use(VueI18n) const i18n = new VueI18n({ locale: ja,浅出 messages: {} }) new Vue({ i18n, el: #app, render: h => h(App) })上述代码定义了日文和英文两种语法,只要改变 locale 的定义值,就能达到切换语言的深入效果。除了上述用法,浅出还支持支持引入 yaml 或者 json 等文件:
<i18n src="./locales.json"></i18n> // locales.json { "en": { "hello": "hello world" },定义 "ja": { "hello": "こんにちは、世界" } }<i18n>其他用法可以查阅使用文档[2];
要让 customBlock 起作用,需要指定 customBlock 的 loader,如果没有指定,对应的块会默默被忽略。?? 中的 webpack 配置:
const path = require(path) const VueLoaderPlugin = require(vue-loader/lib/plugin) module.exports = { mode: development, entry: path.resolve(__dirname, ./main.js), output: { path: path.resolve(__dirname, dist), filename: bundle.js, publicPath: /dist/ }, devServer: { stats: minimal, contentBase: __dirname }, module: { rules: [ { test: /.vue$/, loader: vue-loader }, { test: /.js$/, loader: babel-loader }, // customBlocks 对应的 rule { // 使用 resourceQuery 来为一个没有 lang 的亿华云自定义块匹配一条规则 // 如果找到了一个自定义块的匹配规则,它将会被处理,否则该自定义块会被默默忽略 resourceQuery: /blockType=i18n/, // Rule.type 设置类型用于匹配模块。它防止了 defaultRules 和它们的默认导入行为发生 type: javascript/auto, // 这里指的是 vue-i18n-loader use: [path.resolve(__dirname, ../lib/index.js)] } ] }, plugins: [new VueLoaderPlugin()] }从上述代码可以看到,如果你要在 SFC 中使用 customBlock 功能,只需要下面两步:
实现一个处理 customBlock 的 loader 函数;
配置 webpack.module.rules ,指定 resourceQuery: /blockType=你的块名称/ 然后使用步骤一的 loader 去处理即可;
通常一个 loader 都是具体某一种资源的转换、加载器,但 vue-loader 不是,它能够处理每一个定义在 SFC 中的块:通过拆解 block -> 组合 loader -> 处理 block -> 组合每一个 block 的结果为最终代码的工作流,完成对 SFC 的处理。下面我们就依次详细地拆解这条流水线!
拆解 block
我们知道,使用 vue-loader 一定需要引入 vue-loader-plugin,不然的话就会给你报一个大大的错误:
`vue-loader was used without the corresponding plugin. Make sure to include VueLoaderPlugin in your webpack config.`VueLoaderPlugin 定义在 vue-loader\lib\plugin-webpack4.js:
const id = vue-loader-plugin const NS = vue-loader 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 // 同步钩子,管理所有模块loader normalModuleLoader.tap(id, loaderContext => { loaderContext[NS] = true }) }) } // use webpacks RuleSet utility to normalize user rules const rawRules = compiler.options.module.rules // https://webpack.js.org/configuration/module/#modulerules const { rules } = new RuleSet(rawRules) // 将你定义过的 loader 复制并应用到 .vue 文件里相应语言的块 const clonedRules = rules .filter(r => r !== vueRule) .map(cloneRule) // ... // 个人对这个命名的源码库理解是 pitcher 是投手的意思,进球得分,所以可以理解成给当前的块和 loader 丰富功能 😁 // 给 template 块加 template-loader,给 style 块加 stype-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 } } // 覆盖原来的rules配置 compiler.options.module.rules = [ pitcher, ...clonedRules, ...rules ] } }VueLoaderPlugin 作用是将你定义的其他 loader 添加到 SFC 的各个块中并修改配置中的 module.rules。pitcher-loader[3] 是后续一个重要的角色。阿宝哥的多图详解,一次性搞懂Webpack Loader[4]有详细的分享,没了解过滴童鞋可以先去认识一下这个“投手”的作用。
了解完 VueLoaderPlugin,我们看到 vue-loader:
module.exports = function (source) { const loaderContext = this // ... // 编译 SFC —— 解析.vue文件,生成不同的 block const descriptor = parse({ source, compiler: options.compiler || loadTemplateCompiler(loaderContext), // 默认使用 vue-template-compiler filename, sourceRoot, needMap: sourceMap }) // ... }本小节核心就是这个 parse 方法。将 SFC 代码传通过自定义编译器或者默认的 @vue/component-compiler-utils 去解析。具体执行过程这里就不展开详细分析了,感兴趣童鞋可以前往[咖聊] “模板编译”真经。生成的 descriptor 结果如下图所示:

接下来就针对 descriptor 的b2b供应网每一个 key 去生成第一次代码:
module.exports = function (source) { const loaderContext = this // ... // 编译 SFC —— 解析.vue文件,生成不同的 block const descriptor = parse({ source, compiler: options.compiler || loadTemplateCompiler(loaderContext), // 默认使用 vue-template-compiler filename, sourceRoot, needMap: sourceMap }) // ... // template let templateImport = `var render, staticRenderFns` let templateRequest if (descriptor.template) { const src = descriptor.template.src || resourcePath const idQuery = `&id=${id}` const scopedQuery = hasScoped ? `&scoped=true` : `` const attrsQuery = attrsToQuery(descriptor.template.attrs) const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}` const request = templateRequest = stringifyRequest(src + query) templateImport = `import { render, staticRenderFns } from ${request}` } // script let scriptImport = `var script = {}` if (descriptor.script) { const src = descriptor.script.src || resourcePath const attrsQuery = attrsToQuery(descriptor.script.attrs, js) const query = `?vue&type=script${attrsQuery}${inheritQuery}` const request = stringifyRequest(src + query) scriptImport = ( `import script from ${request}\n` + `export * from ${request}` // support named exports ) } // styles let stylesCode = `` if (descriptor.styles.length) { stylesCode = genStylesCode( loaderContext, descriptor.styles, id, resourcePath, stringifyRequest, needsHotReload, isServer || isShadow // needs explicit injection? ) } 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` // 判断是否有customBlocks,调用genCustomBlocksCode生成自定义块的代码 if (descriptor.customBlocks && descriptor.customBlocks.length) { code += genCustomBlocksCode( descriptor.customBlocks, resourcePath, resourceQuery, stringifyRequest ) } // ...省略一些热更代码 return code } // vue-loader\lib\codegen\customBlocks.js module.exports = function genCustomBlocksCode ( blocks, resourcePath, resourceQuery, stringifyRequest ) { return `\n/* custom blocks */\n` + blocks.map((block, i) => { // i18n有很多种用法,有通过src直接引入其他资源的用法,这里就是获取这个参数 // 对于demo而言,没有定义外部资源,这里是 const src = block.attrs.src || resourcePath // 获取其他属性,demo中就是&locale=en和&locale=ja const attrsQuery = attrsToQuery(block.attrs) // demo中是 const issuerQuery = block.attrs.src ? `&issuerPath=${qs.escape(resourcePath)}` : // demo中是 const inheritQuery = resourceQuery ? `&${resourceQuery.slice(1)}` : const query = `?vue&type=custom&index=${i}&blockType=${qs.escape(block.type)}${issuerQuery}${attrsQuery}${inheritQuery}` return ( `import block${i} from ${stringifyRequest(src + query)}\n` + `if (typeof block${i} === function) block${i}(component)` ) }).join(`\n`) + `\n` }template、style、script 这些块我们直接略过,重点看看 customBlocks 的处理逻辑。逻辑比较简单,遍历 customBlocks 去获取一些 query 变量,最终返回 customBlocks code。我们看看最终通过第一次调用 vue-loader 返回的 code:
/* template块 */ import { render, staticRenderFns } from "./App.vue?vue&type=template&id=a9794c84&" /* script 块 */ import script from "./App.vue?vue&type=script&lang=js&" export * from "./App.vue?vue&type=script&lang=js&" /* normalize component */ import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js" var component = normalizer( script, render, staticRenderFns, false, null, null, null ) /* 自定义块,例子中即 <i18n> 块的代码 */ import block0 from "./App.vue?vue&type=custom&index=0&blockType=i18n&locale=en" if (typeof block0 === function) block0(component) import block1 from "./App.vue?vue&type=custom&index=1&blockType=i18n&locale=ja" if (typeof block1 === function) block1(component) /* hot reload */ if (module.hot) { var api = require("C:\\Jouryjc\\vue-i18n-loader\\node_modules\\vue-hot-reload-api\\dist\\index.js") api.install(require(vue)) if (api.compatible) { module.hot.accept() if (!api.isRecorded(a9794c84)) { api.createRecord(a9794c84, component.options) } else { api.reload(a9794c84, component.options) } module.hot.accept("./App.vue?vue&type=template&id=a9794c84&", function () { api.rerender(a9794c84, { render: render, staticRenderFns: staticRenderFns }) }) } } component.options.__file = "example/App.vue" export default component.exports紧接着继续处理 import:
/* template块 */ import { render, staticRenderFns } from "./App.vue?vue&type=template&id=a9794c84&" /* script 块 */ import script from "./App.vue?vue&type=script&lang=js&" /* 自定义块,例子中即 <i18n> 块的代码 */ import block0 from "./App.vue?vue&type=custom&index=0&blockType=i18n&locale=en" import block1 from "./App.vue?vue&type=custom&index=1&blockType=i18n&locale=ja"组合 loader
我们可以看到,上述所有资源都有 ?vue 的 query 参数,匹配到了 pitcher-loader ,该“投手”登场了。分析下 import block0 from "./App.vue?vue&type=custom&index=0&blockType=i18n&locale=en" 处理:
module.exports.pitch = function (remainingRequest) { const options = loaderUtils.getOptions(this) const { cacheDirectory, cacheIdentifier } = options 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) { // 剔除eslint-loader if (/\.vue$/.test(this.resourcePath)) { loaders = loaders.filter(l => !isESLintLoader(l)) } else { // This is a src import. Just make sure theres not more than 1 instance // of eslint present. loaders = dedupeESLintLoader(loaders) } } // 提取pitcher-loader 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 => { // Important: dedupe since both the original rule // and the cloned rule would match a source import request. // also make sure to dedupe based on loader path. // assumes youd probably never want to apply the same loader on the same // file twice. // Exception: in Vue CLI we do need two instances of postcss-loader // for user config and inline minification. So we need to dedupe baesd on // path AND query to be safe. const seen = new Map() const loaderStrings = [] loaders.forEach(loader => { const identifier = typeof loader === string ? loader : (loader.path + loader.query) const request = typeof loader === string ? loader : loader.request if (!seen.has(identifier)) { seen.set(identifier, true) // loader.request contains both the resolved loader path and its options // query (e.g. ??ref-0) loaderStrings.push(request) } }) return loaderUtils.stringifyRequest(this, -! + [ ...loaderStrings, this.resourcePath + this.resourceQuery ].join(!)) } // script、template、style... // if a custom block has no other matching loader other than vue-loader itself // or cache-loader, we should ignore it // 如果除了vue-loader没有其他的loader,就直接忽略 if (query.type === `custom` && shouldIgnoreCustomBlock(loaders)) { 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) return `import mod from ${request}; export default mod; export * from ${request}` }pitcher-loader 做了 3 件事:
剔除 eslint-loader,避免重复 lint; 剔除 pitcher-loader 自身; 根据不同的 query.type,生成对应的 request,并返回结果;中 customBlocks 返回的结果如下:
import mod from "-!../lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=custom&index=0&blockType=i18n&locale=en"; export default mod; export * from "-!../lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=custom&index=0&blockType=i18n&locale=en" // ja import mod from "-!../lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=custom&index=1&blockType=i18n&locale=ja"; export default mod; export * from "-!../lib/index.js!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=custom&index=1&blockType=i18n&locale=ja"处理 block
根据 import 的表达式,我们可以看到,此时会通过 vue-loader -> vue-i18n-loader 依次处理拿到结果,此时再进入到 vue-loader 跟前面第一次生成 code 不一样的地方是:此时 incomingQuery.type 是有值的。对于 custom 而言,这里就是 custom:
// ... // 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 ) } // ...会执行到 selectBlock:
module.exports = function selectBlock ( descriptor, loaderContext, query, appendExtension ) { // template // script // style // custom if (query.type === custom && query.index != null) { const block = descriptor.customBlocks[query.index] loaderContext.callback( null, block.content, block.map ) return } }最后会执行到 vue-i18n-loader:
const loader: webpack.loader.Loader = function ( source: string | Buffer, sourceMap: RawSourceMap | undefined ): void { if (this.version && Number(this.version) >= 2) { try { // 缓存结果,在输入和依赖没有发生改变时,直接使用缓存结果 this.cacheable && this.cacheable() // 输出结果 this.callback( null, `module.exports = ${generateCode(source, parse(this.resourceQuery))}`, sourceMap ) } catch (err) { this.emitError(err.message) this.callback(err) } } else { const message = support webpack 2 later this.emitError(message) this.callback(new Error(message)) } } /** * 将i18n标签生成代码 * @param {string | Buffer} source * @param {ParsedUrlQuery} query * @returns {string} code */ function generateCode(source: string | Buffer, query: ParsedUrlQuery): string { const data = convert(source, query.lang as string) let value = JSON.parse(data) if (query.locale && typeof query.locale === string) { value = Object.assign({}, { [query.locale]: value }) } // 特殊字符转义,\u2028 -> 行分隔符,\u2029 -> 段落分隔符,\\ 反斜杠 value = JSON.stringify(value) .replace(/\u2028/g, \\u2028) .replace(/\u2029/g, \\u2029) .replace(/\\/g, \\\\) let code = code += `function (Component) { Component.options.__i18n = Component.options.__i18n || [] Component.options.__i18n.push(${value.replace(/u0027/g, u0027)}) delete Component.options._Ctor }\n` return code } /** * 转换各种用法为json字符串 */ function convert(source: string | Buffer, lang: string): string { const value = Buffer.isBuffer(source) ? source.toString() : source switch (lang) { case yaml: case yml: const data = yaml.safeLoad(value) return JSON.stringify(data, undefined, t) case json5: return JSON.stringify(JSON5.parse(value)) default: return value } } export default loader上述代码就比较简单了,拿到 source 生成 value,最终 push 到 Component.options.__i18n 中,针对不同的情况有不同的处理方式(json、yaml等)。
至此,整个 vue 文件就构建结束了, 最终构建完的代码如下:
"./lib/index.js!./node_modules/vue-loader/lib/index.js?!./example/App.vue?vue&type=custom&index=0&blockType=i18n&locale=en": (function (module, exports) { eval("module.exports = function (Component) {\n Component.options.__i18n = Component.options.__i18n || []\n Component.options.__i18n.push({\"en\":{"hello":"hello, world!!!!"}})\n delete Component.options._Ctor\n}\n\n\n//# sourceURL=webpack:///./example/App.vue?./lib!./node_modules/vue-loader/lib??vue-loader-options"); })至于 vue-i18n 怎么识别 Component.options.__i18n 就放一段代码,感兴趣可以去阅读 vue-i18n[5] 的代码哦。
if (options.__i18n) { try { let localeMessages = options.i18n && options.i18n.messages ? options.i18n.messages : {}; options.__i18n.forEach(resource => { localeMessages = merge(localeMessages, JSON.parse(resource)); }); Object.keys(localeMessages).forEach((locale) => { options.i18n.mergeLocaleMessage(locale, localeMessages[locale]); }); } catch (e) { { error(`Cannot parse locale messages via custom blocks.`, e); } } }本文从 vue-i18n 的工具切入,分享了如何在 SFC 中定义一个自定义块。然后从 vue-loader 源码分析了 SFC 的处理流程,整个过程如下图所示:

参考资料
[1]vue-i18n: https://kazupon.github.io/vue-i18n/
[2]使用文档: https://kazupon.github.io/vue-i18n/guide/sfc.html#basic-usage
[3]pitcher-loader: https://webpack.docschina.org/api/loaders/#pitching-loader
[4]多图详解,一次性搞懂Webpack Loader: https://juejin.cn/post/6992754161221632030#heading-3
[5]vue-i18n: https://github.com/kazupon/vue-i18n