深入淺出 Vue-Loader 自定義塊
本文轉(zhuǎn)載自微信公眾號(hào)「碼農(nóng)小余」,作者Jouryjc 。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼農(nóng)小余公眾號(hào)。
本文大綱:
- 通過 vue-i18n 的
了解 customBlocks 和基本配置; - 從源碼層面了解 vue-loader 對(duì) customBlocks 的處理
vue-i18n
vue-i18n[1] 是 Vue 的國際化插件。如果使用 SFC 的方式寫組件的話,可以在 .vue 文件中定義
- <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 的值,就能達(dá)到切換語言的效果。除了上述用法,還支持支持引入 yaml 或者 json 等文件:
- <i18n src="./locales.json"></i18n>
- // locales.json
- {
- "en": {
- "hello": "hello world"
- },
- "ja": {
- "hello": "こんにちは、世界"
- }
- }
要讓 customBlock 起作用,需要指定 customBlock 的 loader,如果沒有指定,對(duì)應(yīng)的塊會(huì)默默被忽略。?? 中的 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 對(duì)應(yīng)的 rule
- {
- // 使用 resourceQuery 來為一個(gè)沒有 lang 的自定義塊匹配一條規(guī)則
- // 如果找到了一個(gè)自定義塊的匹配規(guī)則,它將會(huì)被處理,否則該自定義塊會(huì)被默默忽略
- resourceQuery: /blockType=i18n/,
- // Rule.type 設(shè)置類型用于匹配模塊。它防止了 defaultRules 和它們的默認(rèn)導(dǎo)入行為發(fā)生
- type: 'javascript/auto',
- // 這里指的是 vue-i18n-loader
- use: [path.resolve(__dirname, '../lib/index.js')]
- }
- ]
- },
- plugins: [new VueLoaderPlugin()]
- }
從上述代碼可以看到,如果你要在 SFC 中使用 customBlock 功能,只需要下面兩步:
實(shí)現(xiàn)一個(gè)處理 customBlock 的 loader 函數(shù);
配置 webpack.module.rules ,指定 resourceQuery: /blockType=你的塊名稱/ 然后使用步驟一的 loader 去處理即可;
源碼分析
通常一個(gè) loader 都是具體某一種資源的轉(zhuǎn)換、加載器,但 vue-loader 不是,它能夠處理每一個(gè)定義在 SFC 中的塊:通過拆解 block -> 組合 loader -> 處理 block -> 組合每一個(gè) block 的結(jié)果為最終代碼的工作流,完成對(duì) SFC 的處理。下面我們就依次詳細(xì)地拆解這條流水線!
拆解 block
我們知道,使用 vue-loader 一定需要引入 vue-loader-plugin,不然的話就會(huì)給你報(bào)一個(gè)大大的錯(cuò)誤:
- `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 webpack's 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 復(fù)制并應(yīng)用到 .vue 文件里相應(yīng)語言的塊
- const clonedRules = rules
- .filter(r => r !== vueRule)
- .map(cloneRule)
- // ...
- // 個(gè)人對(duì)這個(gè)命名的理解是 pitcher 是投手的意思,進(jìn)球得分,所以可以理解成給當(dāng)前的塊和 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 的各個(gè)塊中并修改配置中的 module.rules。pitcher-loader[3] 是后續(xù)一個(gè)重要的角色。阿寶哥的多圖詳解,一次性搞懂Webpack Loader[4]有詳細(xì)的分享,沒了解過滴童鞋可以先去認(rèn)識(shí)一下這個(gè)“投手”的作用。
了解完 VueLoaderPlugin,我們看到 vue-loader:
- module.exports = function (source) {
- const loaderContext = this
- // ...
- // 編譯 SFC —— 解析.vue文件,生成不同的 block
- const descriptor = parse({
- source,
- compiler: options.compiler || loadTemplateCompiler(loaderContext), // 默認(rèn)使用 vue-template-compiler
- filename,
- sourceRoot,
- needMap: sourceMap
- })
- // ...
- }
本小節(jié)核心就是這個(gè) parse 方法。將 SFC 代碼傳通過自定義編譯器或者默認(rèn)的 @vue/component-compiler-utils 去解析。具體執(zhí)行過程這里就不展開詳細(xì)分析了,感興趣童鞋可以前往[咖聊] “模板編譯”真經(jīng)。生成的 descriptor 結(jié)果如下圖所示:
接下來就針對(duì) descriptor 的每一個(gè) key 去生成第一次代碼:
- module.exports = function (source) {
- const loaderContext = this
- // ...
- // 編譯 SFC —— 解析.vue文件,生成不同的 block
- const descriptor = parse({
- source,
- compiler: options.compiler || loadTemplateCompiler(loaderContext), // 默認(rèn)使用 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,調(diào)用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直接引入其他資源的用法,這里就是獲取這個(gè)參數(shù)
- // 對(duì)于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 這些塊我們直接略過,重點(diǎn)看看 customBlocks 的處理邏輯。邏輯比較簡(jiǎn)單,遍歷 customBlocks 去獲取一些 query 變量,最終返回 customBlocks code。我們看看最終通過第一次調(diào)用 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
緊接著繼續(xù)處理 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 參數(shù),匹配到了 pitcher-loader ,該“投手”登場(chǎng)了。分析下 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 there's 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 you'd 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,避免重復(fù) lint;
- 剔除 pitcher-loader 自身;
- 根據(jù)不同的 query.type,生成對(duì)應(yīng)的 request,并返回結(jié)果;
中 customBlocks 返回的結(jié)果如下:
- 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
根據(jù) import 的表達(dá)式,我們可以看到,此時(shí)會(huì)通過 vue-loader -> vue-i18n-loader 依次處理拿到結(jié)果,此時(shí)再進(jìn)入到 vue-loader 跟前面第一次生成 code 不一樣的地方是:此時(shí) incomingQuery.type 是有值的。對(duì)于 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
- )
- }
- // ...
會(huì)執(zhí)行到 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
- }
- }
最后會(huì)執(zhí)行到 vue-i18n-loader:
- const loader: webpack.loader.Loader = function (
- source: string | Buffer,
- sourceMap: RawSourceMap | undefined
- ): void {
- if (this.version && Number(this.version) >= 2) {
- try {
- // 緩存結(jié)果,在輸入和依賴沒有發(fā)生改變時(shí),直接使用緩存結(jié)果
- this.cacheable && this.cacheable()
- // 輸出結(jié)果
- 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標(biāo)簽生成代碼
- * @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 })
- }
- // 特殊字符轉(zhuǎn)義,\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
- }
- /**
- * 轉(zhuǎn)換各種用法為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
上述代碼就比較簡(jiǎn)單了,拿到 source 生成 value,最終 push 到 Component.options.__i18n 中,針對(duì)不同的情況有不同的處理方式(json、yaml等)。
至此,整個(gè) vue 文件就構(gòu)建結(jié)束了,
- "./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 怎么識(shí)別 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 中定義一個(gè)自定義塊。然后從 vue-loader 源碼分析了 SFC 的處理流程,整個(gè)過程如下圖所示:
- 從 webpack 構(gòu)建開始,會(huì)調(diào)用到插件,VueLoaderPlugin 在 normalModuleLoader 鉤子上會(huì)被執(zhí)行;
- 在引入 SFC 時(shí),第一次匹配到 vue-loader,會(huì)通過 @vue/component-compiler-utils 將代碼解析成不同的塊,例如 template、script、style、custom;
- 生成的 code,會(huì)繼續(xù)匹配 loader,?vue 會(huì)匹配上“投手”pitcher-loader;
- pitcher-loader 主要做 3 件事:首先因?yàn)?vue 整個(gè)文件已經(jīng)被 lint 處理過了,所以局部代碼時(shí)過濾掉 eslint-loader;其次過濾掉自身 pitcher-loader;最后通過 query.type 去生成不同的 request 和 code;
- 最終 code 會(huì)再次匹配上 vue-loader,此時(shí)第二次執(zhí)行,incomingQuery.type 都會(huì)指定對(duì)應(yīng)的塊,所以會(huì)根據(jù) type 調(diào)用 selectBlock 生成最終的塊代碼。
參考資料
[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