diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80fba103..368015cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,15 @@ jobs: cache: 'yarn' - run: yarn install - run: yarn test + + test-webpack5-inline-match-resource: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set node version to 16 + uses: actions/setup-node@v2 + with: + node-version: 16 + cache: 'yarn' + - run: yarn install + - run: yarn test:match-resource diff --git a/README.md b/README.md index aff9518b..b3f34888 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ - [Documentation](https://vue-loader.vuejs.org) +## v17.1+ Only Options + +- `experimentalInlineMatchResource: boolean`: enable [Inline matchResource](https://webpack.js.org/api/loaders/#inline-matchresource) for rule matching for vue-loader. + ## v16+ Only Options - `reactivityTransform: boolean`: enable [Vue Reactivity Transform](https://github.com/vuejs/rfcs/discussions/369) (SFCs only). diff --git a/jest.config.js b/jest.config.js index 4f710abb..fc6d1b32 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,12 @@ -console.log(`running tests with webpack ${process.env.WEBPACK4 ? '4' : '5'}...`) +const isWebpack4 = process.env.WEBPACK4 + +console.log( + `running tests with webpack ${isWebpack4 ? '4' : '5'}${ + !isWebpack4 && process.env.INLINE_MATCH_RESOURCE + ? ' with inline match resource enabled' + : '' + }...` +) module.exports = { preset: 'ts-jest', diff --git a/package.json b/package.json index bf2d9a17..7fd8ca6c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "build": "tsc", "pretest": "tsc", "test": "jest", + "pretest:match-resource": "tsc", + "test:match-resource": "INLINE_MATCH_RESOURCE=true jest", "pretest:webpack4": "tsc", "test:webpack4": "WEBPACK4=true jest", "dev-example": "node example/devServer.js --config example/webpack.config.js --inline --hot", diff --git a/src/index.ts b/src/index.ts index aa4dfc9d..21be9e5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,12 @@ import { formatError } from './formatError' import VueLoaderPlugin from './plugin' import { canInlineTemplate } from './resolveScript' import { setDescriptor } from './descriptorCache' -import { getOptions, stringifyRequest as _stringifyRequest } from './util' +import { + getOptions, + stringifyRequest as _stringifyRequest, + genMatchResource, + testWebpack5, +} from './util' export { VueLoaderPlugin } @@ -51,6 +56,7 @@ export interface VueLoaderOptions { exposeFilename?: boolean appendExtension?: boolean enableTsInTemplate?: boolean + experimentalInlineMatchResource?: boolean isServerBuild?: boolean } @@ -90,18 +96,23 @@ export default function loader( rootContext, resourcePath, resourceQuery: _resourceQuery = '', + _compiler, } = loaderContext + const isWebpack5 = testWebpack5(_compiler) const rawQuery = _resourceQuery.slice(1) const incomingQuery = qs.parse(rawQuery) const resourceQuery = rawQuery ? `&${rawQuery}` : '' const options = (getOptions(loaderContext) || {}) as VueLoaderOptions + const enableInlineMatchResource = + isWebpack5 && Boolean(options.experimentalInlineMatchResource) const isServer = options.isServerBuild ?? target === 'node' const isProduction = mode === 'production' || process.env.NODE_ENV === 'production' const filename = resourcePath.replace(/\?.*$/, '') + const { descriptor, errors } = parse(source, { filename, sourceMap, @@ -167,10 +178,23 @@ export default function loader( if (script || scriptSetup) { const lang = script?.lang || scriptSetup?.lang isTS = !!(lang && /tsx?/.test(lang)) + const externalQuery = Boolean(script && !scriptSetup && script.src) + ? `&external` + : `` const src = (script && !scriptSetup && script.src) || resourcePath const attrsQuery = attrsToQuery((scriptSetup || script)!.attrs, 'js') - const query = `?vue&type=script${attrsQuery}${resourceQuery}` - const scriptRequest = stringifyRequest(src + query) + const query = `?vue&type=script${attrsQuery}${resourceQuery}${externalQuery}` + + let scriptRequest: string + + if (enableInlineMatchResource) { + scriptRequest = stringifyRequest( + genMatchResource(this, src, query, lang || 'js') + ) + } else { + scriptRequest = stringifyRequest(src + query) + } + scriptImport = `import script from ${scriptRequest}\n` + // support named exports @@ -184,13 +208,27 @@ export default function loader( const useInlineTemplate = canInlineTemplate(descriptor, isProduction) if (descriptor.template && !useInlineTemplate) { const src = descriptor.template.src || resourcePath + const externalQuery = Boolean(descriptor.template.src) ? `&external` : `` const idQuery = `&id=${id}` const scopedQuery = hasScoped ? `&scoped=true` : `` const attrsQuery = attrsToQuery(descriptor.template.attrs) const tsQuery = options.enableTsInTemplate !== false && isTS ? `&ts=true` : `` - const query = `?vue&type=template${idQuery}${scopedQuery}${tsQuery}${attrsQuery}${resourceQuery}` - templateRequest = stringifyRequest(src + query) + const query = `?vue&type=template${idQuery}${scopedQuery}${tsQuery}${attrsQuery}${resourceQuery}${externalQuery}` + + if (enableInlineMatchResource) { + templateRequest = stringifyRequest( + genMatchResource( + this, + src, + query, + options.enableTsInTemplate !== false && isTS ? 'ts' : 'js' + ) + ) + } else { + templateRequest = stringifyRequest(src + query) + } + templateImport = `import { ${renderFnName} } from ${templateRequest}` propsToAttach.push([renderFnName, renderFnName]) } @@ -205,12 +243,23 @@ export default function loader( .forEach((style, i) => { const src = style.src || resourcePath const attrsQuery = attrsToQuery(style.attrs, 'css') + const lang = String(style.attrs.lang || 'css') // make sure to only pass id when necessary so that we don't inject // duplicate tags when multiple components import the same css file const idQuery = !style.src || style.scoped ? `&id=${id}` : `` const inlineQuery = asCustomElement ? `&inline` : `` - const query = `?vue&type=style&index=${i}${idQuery}${inlineQuery}${attrsQuery}${resourceQuery}` - const styleRequest = stringifyRequest(src + query) + const externalQuery = Boolean(style.src) ? `&external` : `` + const query = `?vue&type=style&index=${i}${idQuery}${inlineQuery}${attrsQuery}${resourceQuery}${externalQuery}` + + let styleRequest + if (enableInlineMatchResource) { + styleRequest = stringifyRequest( + genMatchResource(this, src, query, lang) + ) + } else { + styleRequest = stringifyRequest(src + query) + } + if (style.module) { if (asCustomElement) { loaderContext.emitError( @@ -283,9 +332,27 @@ export default function loader( const issuerQuery = block.attrs.src ? `&issuerPath=${qs.escape(resourcePath)}` : '' - const query = `?vue&type=custom&index=${i}${blockTypeQuery}${issuerQuery}${attrsQuery}${resourceQuery}` + + const externalQuery = Boolean(block.attrs.src) ? `&external` : `` + const query = `?vue&type=custom&index=${i}${blockTypeQuery}${issuerQuery}${attrsQuery}${resourceQuery}${externalQuery}` + + let customRequest + + if (enableInlineMatchResource) { + customRequest = stringifyRequest( + genMatchResource( + this, + src as string, + query, + block.attrs.lang as string + ) + ) + } else { + customRequest = stringifyRequest(src + query) + } + return ( - `import block${i} from ${stringifyRequest(src + query)}\n` + + `import block${i} from ${customRequest}\n` + `if (typeof block${i} === 'function') block${i}(script)` ) }) diff --git a/src/pitcher.ts b/src/pitcher.ts index e90acc9a..f12f5405 100644 --- a/src/pitcher.ts +++ b/src/pitcher.ts @@ -1,6 +1,6 @@ import type { LoaderDefinitionFunction, LoaderContext } from 'webpack' import * as qs from 'querystring' -import { stringifyRequest } from './util' +import { getOptions, stringifyRequest, testWebpack5 } from './util' import { VueLoaderOptions } from '.' const selfPath = require.resolve('./index') @@ -58,7 +58,40 @@ export const pitch = function () { }) // Inject style-post-loader before css-loader for scoped CSS and trimming + const isWebpack5 = testWebpack5(context._compiler) + const options = (getOptions(context) || {}) as VueLoaderOptions if (query.type === `style`) { + if (isWebpack5 && context._compiler?.options.experiments.css) { + // If user enables `experiments.css`, then we are trying to emit css code directly. + // Although we can target requests like `xxx.vue?type=style` to match `type: "css"`, + // it will make the plugin a mess. + if (!options.experimentalInlineMatchResource) { + context.emitError( + new Error( + '`experimentalInlineMatchResource` should be enabled if `experiments.css` enabled currently' + ) + ) + return '' + } + + if (query.inline || query.module) { + context.emitError( + new Error( + '`inline` or `module` is currently not supported with `experiments.css` enabled' + ) + ) + return '' + } + + const loaderString = [stylePostLoaderPath, ...loaders] + .map((loader) => { + return typeof loader === 'string' ? loader : loader.request + }) + .join('!') + return `@import "${context.resourcePath}${ + query.lang ? `.${query.lang}` : '' + }${context.resourceQuery}!=!-!${loaderString}!${context.resource}";` + } const cssLoaderIndex = loaders.findIndex(isCSSLoader) if (cssLoaderIndex > -1) { // if inlined, ignore any loaders after css-loader and replace w/ inline @@ -71,7 +104,8 @@ export const pitch = function () { return genProxyModule( [...afterLoaders, stylePostLoaderPath, ...beforeLoaders], context, - !!query.module || query.inline != null + !!query.module || query.inline != null, + (query.lang as string) || 'css' ) } } @@ -84,15 +118,21 @@ export const pitch = function () { // Rewrite request. Technically this should only be done when we have deduped // loaders. But somehow this is required for block source maps to work. - return genProxyModule(loaders, context, query.type !== 'template') + return genProxyModule( + loaders, + context, + query.type !== 'template', + query.ts ? 'ts' : (query.lang as string) + ) } function genProxyModule( loaders: (Loader | string)[], context: LoaderContext, - exportDefault = true + exportDefault = true, + lang = 'js' ) { - const request = genRequest(loaders, context) + const request = genRequest(loaders, lang, context) // return a proxy module which simply re-exports everything from the // actual request. Note for template blocks the compiled module has no // default export. @@ -104,12 +144,28 @@ function genProxyModule( function genRequest( loaders: (Loader | string)[], + lang: string, context: LoaderContext ) { + const isWebpack5 = testWebpack5(context._compiler) + const options = (getOptions(context) || {}) as VueLoaderOptions + const enableInlineMatchResource = + isWebpack5 && options.experimentalInlineMatchResource + const loaderStrings = loaders.map((loader) => { return typeof loader === 'string' ? loader : loader.request }) const resource = context.resourcePath + context.resourceQuery + + if (enableInlineMatchResource) { + return stringifyRequest( + context, + `${context.resourcePath}${lang ? `.${lang}` : ''}${ + context.resourceQuery + }!=!-!${[...loaderStrings, resource].join('!')}` + ) + } + return stringifyRequest( context, '-!' + [...loaderStrings, resource].join('!') diff --git a/src/plugin.ts b/src/plugin.ts index a7b4a42d..72caaadc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,19 +1,26 @@ -import webpack from 'webpack' import type { Compiler } from 'webpack' +import { testWebpack5 } from './util' declare class VueLoaderPlugin { static NS: string apply(compiler: Compiler): void } -let Plugin: typeof VueLoaderPlugin +const NS = 'vue-loader' -if (webpack.version && webpack.version[0] > '4') { - // webpack5 and upper - Plugin = require('./pluginWebpack5').default -} else { - // webpack4 and lower - Plugin = require('./pluginWebpack4').default +class Plugin { + static NS = NS + apply(compiler: Compiler) { + let Ctor: typeof VueLoaderPlugin + if (testWebpack5(compiler)) { + // webpack5 and upper + Ctor = require('./pluginWebpack5').default + } else { + // webpack4 and lower + Ctor = require('./pluginWebpack4').default + } + new Ctor().apply(compiler) + } } export default Plugin diff --git a/src/pluginWebpack5.ts b/src/pluginWebpack5.ts index 75152851..649cd54c 100644 --- a/src/pluginWebpack5.ts +++ b/src/pluginWebpack5.ts @@ -1,6 +1,6 @@ import * as qs from 'querystring' import type { VueLoaderOptions } from './' -import type { RuleSetRule, Compiler } from 'webpack' +import type { RuleSetRule, Compiler, RuleSetUse } from 'webpack' import { needHMR } from './util' import { clientCache, typeDepToSFCMap } from './resolveScript' import { compiler as vueCompiler } from './compiler' @@ -146,7 +146,7 @@ class VueLoaderPlugin { ) } - // get the normlized "use" for vue files + // get the normalized "use" for vue files const vueUse = vueRules .filter((rule) => rule.type === 'use') .map((rule) => rule.value) @@ -170,6 +170,8 @@ class VueLoaderPlugin { const vueLoaderUse = vueUse[vueLoaderUseIndex] const vueLoaderOptions = (vueLoaderUse.options = vueLoaderUse.options || {}) as VueLoaderOptions + const enableInlineMatchResource = + vueLoaderOptions.experimentalInlineMatchResource // for each user rule (except the vue rule), create a cloned rule // that targets the corresponding language blocks in *.vue files. @@ -221,16 +223,53 @@ class VueLoaderPlugin { const parsed = qs.parse(query.slice(1)) return parsed.vue != null }, + options: vueLoaderOptions, } // replace original rules - compiler.options.module!.rules = [ - pitcher, - ...jsRulesForRenderFn, - templateCompilerRule, - ...clonedRules, - ...rules, - ] + if (enableInlineMatchResource) { + // Match rules using `vue-loader` + const vueLoaderRules = rules.filter((rule) => { + const matchOnce = (use?: RuleSetUse) => { + let loaderString = '' + + if (!use) { + return loaderString + } + + if (typeof use === 'string') { + loaderString = use + } else if (Array.isArray(use)) { + loaderString = matchOnce(use[0]) + } else if (typeof use === 'object' && use.loader) { + loaderString = use.loader + } + return loaderString + } + + const loader = rule.loader || matchOnce(rule.use) + return ( + loader === require('../package.json').name || + loader.startsWith(require.resolve('./index')) + ) + }) + + compiler.options.module!.rules = [ + pitcher, + ...rules.filter((rule) => !vueLoaderRules.includes(rule)), + templateCompilerRule, + ...clonedRules, + ...vueLoaderRules, + ] + } else { + compiler.options.module!.rules = [ + pitcher, + ...jsRulesForRenderFn, + templateCompilerRule, + ...clonedRules, + ...rules, + ] + } // 3.3 HMR support for imported types if ( diff --git a/src/util.ts b/src/util.ts index 1a2718fa..998f1401 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,5 @@ import type { Compiler, LoaderContext } from 'webpack' +import qs from 'querystring' import type { SFCDescriptor, CompilerOptions } from 'vue/compiler-sfc' import type { VueLoaderOptions } from '.' import * as path from 'path' @@ -163,3 +164,36 @@ export function stringifyRequest( .join('!') ) } + +export function genMatchResource( + context: LoaderContext, + resourcePath: string, + resourceQuery?: string, + lang?: string +) { + resourceQuery = resourceQuery || '' + + const loaders: string[] = [] + const parsedQuery = qs.parse(resourceQuery.slice(1)) + + // process non-external resources + if ('vue' in parsedQuery && !('external' in parsedQuery)) { + const currentRequest = context.loaders + .slice(context.loaderIndex) + .map((obj) => obj.request) + loaders.push(...currentRequest) + } + const loaderString = loaders.join('!') + + return `${resourcePath}${lang ? `.${lang}` : ''}${resourceQuery}!=!${ + loaderString ? `${loaderString}!` : '' + }${resourcePath}${resourceQuery}` +} + +export const testWebpack5 = (compiler?: Compiler) => { + if (!compiler) { + return false + } + const webpackVersion = compiler?.webpack?.version + return Boolean(webpackVersion && Number(webpackVersion.split('.')[0]) > 4) +} diff --git a/test/advanced.spec.ts b/test/advanced.spec.ts index 9fee7e83..eec28bc9 100644 --- a/test/advanced.spec.ts +++ b/test/advanced.spec.ts @@ -1,6 +1,12 @@ import { SourceMapConsumer } from 'source-map' import { fs as mfs } from 'memfs' -import { bundle, mockBundleAndRun, normalizeNewline, genId } from './utils' +import { + bundle, + mockBundleAndRun, + normalizeNewline, + genId, + DEFAULT_VUE_USE, +} from './utils' const MiniCssExtractPlugin = require('mini-css-extract-plugin') @@ -10,7 +16,7 @@ test('support chaining with other loaders', async () => { modify: (config) => { config!.module!.rules[0] = { test: /\.vue$/, - use: ['vue-loader', require.resolve('./mock-loaders/js')], + use: [DEFAULT_VUE_USE, require.resolve('./mock-loaders/js')], } }, }) @@ -24,7 +30,7 @@ test.skip('inherit queries on files', async () => { modify: (config) => { config!.module!.rules[0] = { test: /\.vue$/, - use: ['vue-loader', require.resolve('./mock-loaders/query')], + use: [DEFAULT_VUE_USE, require.resolve('./mock-loaders/query')], } }, }) @@ -92,7 +98,7 @@ test('extract CSS', async () => { config.module.rules = [ { test: /\.vue$/, - use: 'vue-loader', + use: [DEFAULT_VUE_USE], }, { test: /\.css$/, @@ -126,7 +132,7 @@ test('extract CSS with code spliting', async () => { config.module.rules = [ { test: /\.vue$/, - use: 'vue-loader', + use: [DEFAULT_VUE_USE], }, { test: /\.css$/, @@ -153,7 +159,10 @@ test('support rules with oneOf', async () => { entry, modify: (config: any) => { config!.module!.rules = [ - { test: /\.vue$/, loader: 'vue-loader' }, + { + test: /\.vue$/, + use: [DEFAULT_VUE_USE], + }, { test: /\.css$/, use: 'style-loader', diff --git a/test/edgeCases.spec.ts b/test/edgeCases.spec.ts index d5394b3d..a0c5aadf 100644 --- a/test/edgeCases.spec.ts +++ b/test/edgeCases.spec.ts @@ -1,6 +1,12 @@ import * as path from 'path' import webpack from 'webpack' -import { mfs, bundle, mockBundleAndRun, normalizeNewline } from './utils' +import { + mfs, + bundle, + mockBundleAndRun, + normalizeNewline, + DEFAULT_VUE_USE, +} from './utils' // @ts-ignore function assertComponent({ @@ -37,7 +43,7 @@ test('vue rule with include', async () => { config.module.rules[i] = { test: /\.vue$/, include: /fixtures/, - loader: 'vue-loader', + use: [DEFAULT_VUE_USE], } }, }) @@ -52,7 +58,7 @@ test('test-less oneOf rules', async () => { config!.module!.rules = [ { test: /\.vue$/, - loader: 'vue-loader', + use: [DEFAULT_VUE_USE], }, { oneOf: [ @@ -79,12 +85,7 @@ test('normalize multiple use + options', async () => { ) config!.module!.rules[i] = { test: /\.vue$/, - use: [ - { - loader: 'vue-loader', - options: {}, - }, - ], + use: [DEFAULT_VUE_USE], } }, }) diff --git a/test/style.spec.ts b/test/style.spec.ts index 94a04eb0..4bb998db 100644 --- a/test/style.spec.ts +++ b/test/style.spec.ts @@ -1,4 +1,9 @@ -import { mockBundleAndRun, genId, normalizeNewline } from './utils' +import { + mockBundleAndRun, + genId, + normalizeNewline, + DEFAULT_VUE_USE, +} from './utils' test('scoped style', async () => { const { window, instance, componentModule } = await mockBundleAndRun({ @@ -109,7 +114,7 @@ test('CSS Modules', async () => { config!.module!.rules = [ { test: /\.vue$/, - loader: 'vue-loader', + use: [DEFAULT_VUE_USE], }, { test: /\.css$/, @@ -178,7 +183,7 @@ test('CSS Modules Extend', async () => { config!.module!.rules = [ { test: /\.vue$/, - loader: 'vue-loader', + use: [DEFAULT_VUE_USE], }, { test: /\.css$/, diff --git a/test/template.spec.ts b/test/template.spec.ts index 52eda51c..e46758a5 100644 --- a/test/template.spec.ts +++ b/test/template.spec.ts @@ -1,5 +1,5 @@ import * as path from 'path' -import { mockBundleAndRun, normalizeNewline } from './utils' +import { DEFAULT_VUE_USE, mockBundleAndRun, normalizeNewline } from './utils' test('apply babel transformations to expressions in template', async () => { const { instance } = await mockBundleAndRun({ @@ -111,7 +111,7 @@ test('should allow process custom file', async () => { rules: [ { test: /\.svg$/, - loader: 'vue-loader', + use: [DEFAULT_VUE_USE], }, ], }, diff --git a/test/utils.ts b/test/utils.ts index 628d24da..0214fd4c 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -9,6 +9,13 @@ import { JSDOM, VirtualConsole } from 'jsdom' import { VueLoaderPlugin } from '..' import type { VueLoaderOptions } from '..' +export const DEFAULT_VUE_USE = { + loader: 'vue-loader', + options: { + experimentalInlineMatchResource: Boolean(process.env.INLINE_MATCH_RESOURCE), + }, +} + const baseConfig: webpack.Configuration = { mode: 'development', devtool: false, @@ -29,11 +36,7 @@ const baseConfig: webpack.Configuration = { rules: [ { test: /\.vue$/, - loader: 'vue-loader', - }, - { - test: /\.css$/, - use: ['style-loader', 'css-loader'], + use: [DEFAULT_VUE_USE], }, { test: /\.ts$/, @@ -73,16 +76,41 @@ export function bundle( }> { let config: BundleOptions = merge({}, baseConfig, options) + if (!options.experiments?.css) { + config.module?.rules?.push({ + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }) + } + if (config.vue && config.module) { - const vueOptions = options.vue + const vueOptions = { + // Test experimental inline match resource by default + experimentalInlineMatchResource: Boolean( + process.env.INLINE_MATCH_RESOURCE + ), + ...options.vue, + } + delete config.vue const vueIndex = config.module.rules!.findIndex( (r: any) => r.test instanceof RegExp && r.test.test('.vue') ) const vueRule = config.module.rules![vueIndex] - config.module.rules![vueIndex] = Object.assign({}, vueRule, { - options: vueOptions, - }) + + // Detect `Rule.use` or `Rule.loader` and `Rule.options` combination + if (vueRule && typeof vueRule === 'object' && Array.isArray(vueRule.use)) { + // Vue usually locates at the first loader + if (typeof vueRule.use?.[0] === 'object') { + vueRule.use[0] = Object.assign({}, vueRule.use[0], { + options: vueOptions, + }) + } + } else { + config.module.rules![vueIndex] = Object.assign({}, vueRule, { + options: vueOptions, + }) + } } if (typeof config.entry === 'string' && /\.vue/.test(config.entry)) { diff --git a/tsconfig.json b/tsconfig.json index 15b7fee1..aead67b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,13 +14,7 @@ "noImplicitAny": true, "removeComments": false, "skipLibCheck": true, - "lib": [ - "es6", - "es7", - "DOM" - ] + "lib": ["es6", "es7", "DOM"] }, - "include": [ - "src" - ] + "include": ["src"] }