feat: auto-import for composables (#1176)

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
Anthony Fu 2021-10-20 17:47:18 +08:00 committed by GitHub
parent f950fe0d8a
commit 550a9f2e12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 320 additions and 100 deletions

View File

@ -6,4 +6,41 @@ head.title: Composables directory
# Composables directory
Nuxt will soon support a `composables/` directory to auto import your Vue composables into your application when used, learn more on the [open issue](https://github.com/nuxt/framework/issues/639).
Nuxt 3 supports `composables/` directory to auto import your Vue composables into your application and use using auto imports!
Example: (using named exports)
```js [composables/useFoo.ts]
import { useState } from '#app'
export const useFoo () {
return useState('foo', () => 'bar')
}
```
Example: (using default export)
```js [composables/use-foo.ts or composables/useFoo.ts]
import { useState } from '#app'
// It will be available as useFoo()
export default function () {
return 'foo'
}
```
You can now auto import it:
```vue [app.vue]
<template>
<div>
{{ foo }}
</div>
</template>
<script setup>
const foo = useFoo()
</script>
```

View File

@ -0,0 +1,17 @@
<template>
<div>
{{ a }}
{{ b }}
{{ c }}
{{ d }}
{{ foo }}
</div>
</template>
<script setup>
const a = useA()
const b = useB()
const c = useC()
const d = useD()
const foo = useFoo()
</script>

View File

@ -0,0 +1,23 @@
import { useState } from '#app'
export function useA () {
return 'a'
}
function useB () {
return 'b'
}
function _useC () {
return 'c'
}
export const useD = () => {
return 'd'
}
export { useB, _useC as useC }
export default function () {
return useState('foo', () => 'bar')
}

View File

@ -0,0 +1,4 @@
import { defineNuxtConfig } from 'nuxt3'
export default defineNuxtConfig({
})

View File

@ -0,0 +1,12 @@
{
"name": "example-with-composables",
"private": true,
"devDependencies": {
"nuxt3": "latest"
},
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"start": "node .output/server/index.mjs"
}
}

View File

@ -41,6 +41,7 @@ export interface NuxtHooks {
// Auto imports
'autoImports:sources': (autoImportSources: AutoImportSource[]) => HookResult
'autoImports:extend': (autoImports: AutoImport[]) => HookResult
'autoImports:dirs': (dirs: string[]) => HookResult
// Components
'components:dirs': (dirs: ComponentsOptions['dirs']) => HookResult

View File

@ -46,4 +46,10 @@ export interface AutoImportsOptions {
* [experimental] Use globalThis injection instead of transform for development
*/
global?: boolean
/**
* Additional directories to scan composables from
*
* By default <rootDir>/composables is added
*/
dirs?: []
}

View File

@ -74,20 +74,26 @@ export default defineNuxtCommand({
// TODO: Watcher service, modules, and requireTree
const dLoad = debounce(load, 250)
const watcher = chokidar.watch([rootDir], { ignoreInitial: true, depth: 1 })
watcher.on('all', (_event, file) => {
watcher.on('all', (event, file) => {
if (!currentNuxt) { return }
if (file.startsWith(currentNuxt.options.buildDir)) { return }
if (file.match(/nuxt\.config\.(js|ts|mjs|cjs)$/)) {
dLoad(true, `${relative(rootDir, file)} updated`)
}
if (['addDir', 'unlinkDir'].includes(_event) && file.match(/pages$/)) {
dLoad(true, `Directory \`pages/\` ${_event === 'addDir' ? 'created' : 'removed'}`)
}
if (['addDir', 'unlinkDir'].includes(_event) && file.match(/components$/)) {
dLoad(true, `Directory \`components/\` ${_event === 'addDir' ? 'created' : 'removed'}`)
}
if (['add', 'unlink'].includes(_event) && file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) {
dLoad(true, `\`${relative(rootDir, file)}\` ${_event === 'add' ? 'created' : 'removed'}`)
const isDirChange = ['addDir', 'unlinkDir'].includes(event)
const isFileChange = ['add', 'unlink'].includes(event)
const reloadDirs = ['pages', 'components', 'composables']
if (isDirChange) {
const dir = reloadDirs.find(dir => file.endsWith(dir))
if (dir) {
dLoad(true, `Directory \`${dir}/\` ${event === 'addDir' ? 'created' : 'removed'}`)
}
} else if (isFileChange) {
if (file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) {
dLoad(true, `\`${relative(rootDir, file)}\` ${event === 'add' ? 'created' : 'removed'}`)
}
}
})

View File

@ -0,0 +1,39 @@
import { promises as fsp, existsSync } from 'fs'
import { parse as parsePath, join } from 'pathe'
import globby from 'globby'
import { findExports } from 'mlly'
import { camelCase } from 'scule'
import { AutoImport } from '@nuxt/kit'
import { filterInPlace } from './utils'
export async function scanForComposables (dir: string, autoImports: AutoImport[]) {
if (!existsSync(dir)) { return }
const files = await globby(['*.{ts,js,tsx,jsx,mjs,cjs,mts,cts}'], { cwd: dir })
await Promise.all(
files.map(async (file) => {
const importPath = join(dir, file)
// Remove original entries from the same import (for build watcher)
filterInPlace(autoImports, i => i.from !== importPath)
const code = await fsp.readFile(join(dir, file), 'utf-8')
const exports = findExports(code)
const defaultExport = exports.find(i => i.type === 'default')
if (defaultExport) {
autoImports.push({ name: 'default', as: camelCase(parsePath(file).name), from: importPath })
}
for (const exp of exports) {
if (exp.type === 'named') {
for (const name of exp.names) {
autoImports.push({ name, as: name, from: importPath })
}
} else if (exp.type === 'declaration') {
autoImports.push({ name: exp.name, as: exp.name, from: importPath })
}
}
})
)
}

View File

@ -0,0 +1,42 @@
import type { AutoImport } from '@nuxt/kit'
export interface AutoImportContext {
autoImports: AutoImport[]
matchRE: RegExp
map: Map<string, AutoImport>
}
export function createAutoImportContext (): AutoImportContext {
return {
autoImports: [],
map: new Map(),
matchRE: /__never__/
}
}
export function updateAutoImportContext (ctx: AutoImportContext) {
// Detect duplicates
const usedNames = new Set()
for (const autoImport of ctx.autoImports) {
if (usedNames.has(autoImport.as)) {
autoImport.disabled = true
console.warn(`Disabling duplicate auto import '${autoImport.as}' (imported from '${autoImport.from}')`)
} else {
usedNames.add(autoImport.as)
}
}
// Filter out disabled auto imports
ctx.autoImports = ctx.autoImports.filter(i => i.disabled !== true)
// Create regex
ctx.matchRE = new RegExp(`\\b(${ctx.autoImports.map(i => i.as).join('|')})\\b`, 'g')
// Create map
ctx.map.clear()
for (const autoImport of ctx.autoImports) {
ctx.map.set(autoImport.as, autoImport)
}
return ctx
}

View File

@ -1,15 +1,19 @@
import { addVitePlugin, addWebpackPlugin, defineNuxtModule, addTemplate, resolveAlias, addPluginTemplate, AutoImport } from '@nuxt/kit'
import { addVitePlugin, addWebpackPlugin, defineNuxtModule, addTemplate, resolveAlias, addPluginTemplate, useNuxt } from '@nuxt/kit'
import type { AutoImportsOptions } from '@nuxt/kit'
import { isAbsolute, relative, resolve } from 'pathe'
import { isAbsolute, join, relative, resolve, normalize } from 'pathe'
import { TransformPlugin } from './transform'
import { Nuxt3AutoImports } from './imports'
import { scanForComposables } from './composables'
import { toImports } from './utils'
import { AutoImportContext, createAutoImportContext, updateAutoImportContext } from './context'
export default defineNuxtModule<AutoImportsOptions>({
name: 'auto-imports',
configKey: 'autoImports',
defaults: {
sources: Nuxt3AutoImports,
global: false
global: false,
dirs: []
},
async setup (options, nuxt) {
// Allow modules extending sources
@ -18,91 +22,96 @@ export default defineNuxtModule<AutoImportsOptions>({
// Filter disabled sources
options.sources = options.sources.filter(source => source.disabled !== true)
// Create a context to share state between module internals
const ctx = createAutoImportContext()
// Resolve autoimports from sources
let autoImports: AutoImport[] = []
for (const source of options.sources) {
for (const importName of source.names) {
if (typeof importName === 'string') {
autoImports.push({ name: importName, as: importName, from: source.from })
ctx.autoImports.push({ name: importName, as: importName, from: source.from })
} else {
autoImports.push({ name: importName.name, as: importName.as || importName.name, from: source.from })
ctx.autoImports.push({ name: importName.name, as: importName.as || importName.name, from: source.from })
}
}
}
// Allow modules extending resolved imports
await nuxt.callHook('autoImports:extend', autoImports)
// composables/ dirs
let composablesDirs = [
join(nuxt.options.srcDir, 'composables'),
...options.dirs
]
await nuxt.callHook('autoImports:dirs', composablesDirs)
composablesDirs = composablesDirs.map(dir => normalize(dir))
// Disable duplicate auto imports
const usedNames = new Set()
for (const autoImport of autoImports) {
if (usedNames.has(autoImport.as)) {
autoImport.disabled = true
console.warn(`Disabling duplicate auto import '${autoImport.as}' (imported from '${autoImport.from}')`)
} else {
usedNames.add(autoImport.as)
}
}
// Filter disabled imports
autoImports = autoImports.filter(i => i.disabled !== true)
// temporary disable #746
// @ts-ignore
// Transpile and injection
// @ts-ignore temporary disabled due to #746
if (nuxt.options.dev && options.global) {
// Add all imports to globalThis in development mode
addPluginTemplate({
filename: 'auto-imports.mjs',
src: '',
getContents: () => {
const imports = toImports(autoImports)
const globalThisSet = autoImports.map(i => `globalThis.${i.as} = ${i.as};`).join('\n')
const imports = toImports(ctx.autoImports)
const globalThisSet = ctx.autoImports.map(i => `globalThis.${i.as} = ${i.as};`).join('\n')
return `${imports}\n\n${globalThisSet}\n\nexport default () => {};`
}
})
} else {
// Transform to inject imports in production mode
addVitePlugin(TransformPlugin.vite(autoImports))
addWebpackPlugin(TransformPlugin.webpack(autoImports))
addVitePlugin(TransformPlugin.vite(ctx))
addWebpackPlugin(TransformPlugin.webpack(ctx))
}
// Add types
const resolved = {}
const r = (id: string) => {
if (resolved[id]) { return resolved[id] }
let path = resolveAlias(id, nuxt.options.alias)
if (isAbsolute(path)) {
path = relative(nuxt.options.buildDir, path)
const updateAutoImports = async () => {
// Scan composables/
for (const composablesDir of composablesDirs) {
await scanForComposables(composablesDir, ctx.autoImports)
}
// Remove file extension for benefit of TypeScript
path = path.replace(/\.[a-z]+$/, '')
resolved[id] = path
return path
// Allow modules extending
await nuxt.callHook('autoImports:extend', ctx.autoImports)
// Update context
updateAutoImportContext(ctx)
// Generate types
generateDts(ctx)
}
await updateAutoImports()
addTemplate({
filename: 'auto-imports.d.ts',
write: true,
getContents: () => `// Generated by auto imports
declare global {
${autoImports.map(i => ` const ${i.as}: typeof import('${r(i.from)}')['${i.name}']`).join('\n')}
}\nexport {}`
})
nuxt.hook('prepare:types', ({ references }) => {
references.push({ path: resolve(nuxt.options.buildDir, 'auto-imports.d.ts') })
// Watch composables/ directory
nuxt.hook('builder:watch', async (_, path) => {
const _resolved = resolve(nuxt.options.srcDir, path)
if (composablesDirs.find(dir => _resolved.startsWith(dir))) {
await updateAutoImports()
}
})
}
})
function toImports (autoImports: AutoImport[]) {
const map: Record<string, Set<string>> = {}
for (const autoImport of autoImports) {
if (!map[autoImport.from]) {
map[autoImport.from] = new Set()
function generateDts (ctx: AutoImportContext) {
const nuxt = useNuxt()
const resolved = {}
const r = (id: string) => {
if (resolved[id]) { return resolved[id] }
let path = resolveAlias(id, nuxt.options.alias)
if (isAbsolute(path)) {
path = relative(nuxt.options.buildDir, path)
}
map[autoImport.from].add(autoImport.as)
// Remove file extension for benefit of TypeScript
path = path.replace(/\.[a-z]+$/, '')
resolved[id] = path
return path
}
return Object.entries(map)
.map(([name, imports]) => `import { ${Array.from(imports).join(', ')} } from '${name}';`)
.join('\n')
addTemplate({
filename: 'auto-imports.d.ts',
write: true,
getContents: () => `// Generated by auto imports
declare global {
${ctx.autoImports.map(i => ` const ${i.as}: typeof import('${r(i.from)}')['${i.name}']`).join('\n')}
}
export {}
`
})
}

View File

@ -1,6 +1,7 @@
import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import type { AutoImport } from '@nuxt/kit'
import { toImports } from './utils'
import { AutoImportContext } from './context'
const excludeRE = [
// imported from other module
@ -22,15 +23,7 @@ function stripeComments (code: string) {
.replace(singlelineCommentsRE, '')
}
export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => {
const matchRE = new RegExp(`\\b(${autoImports.map(i => i.as).join('|')})\\b`, 'g')
// Create an internal map for faster lookup
const autoImportMap = new Map<string, AutoImport>()
for (const autoImport of autoImports) {
autoImportMap.set(autoImport.as, autoImport)
}
export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => {
return {
name: 'nuxt-auto-imports-transform',
enforce: 'post',
@ -60,7 +53,7 @@ export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => {
const withoutComment = stripeComments(code)
// find all possible injection
const matched = new Set(Array.from(withoutComment.matchAll(matchRE)).map(i => i[1]))
const matched = new Set(Array.from(withoutComment.matchAll(ctx.matchRE)).map(i => i[1]))
// remove those already defined
for (const regex of excludeRE) {
@ -78,28 +71,11 @@ export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => {
return null
}
const modules: Record<string, string[]> = {}
// group by module name
Array.from(matched).forEach((name) => {
const moduleName = autoImportMap.get(name).from
if (!modules[moduleName]) {
modules[moduleName] = []
}
modules[moduleName].push(name)
})
// Needed for webpack4/bridge support
// For webpack4/bridge support
const isCJSContext = code.includes('require(')
// stringify import
const imports = !isCJSContext
? Object.entries(modules)
.map(([moduleName, names]) => `import { ${names.join(',')} } from '${moduleName}';`)
.join('')
: Object.entries(modules)
.map(([moduleName, names]) => `const { ${names.join(',')} } = require('${moduleName}');`)
.join('')
const matchedImports = Array.from(matched).map(name => ctx.map.get(name)).filter(Boolean)
const imports = toImports(matchedImports, isCJSContext)
return imports + code
}

View File

@ -0,0 +1,36 @@
import type { AutoImport } from '@nuxt/kit'
export function toImports (autoImports: AutoImport[], isCJS = false) {
const aliasKeyword = isCJS ? ' : ' : ' as '
const map: Record<string, Set<string>> = {}
for (const autoImport of autoImports) {
if (!map[autoImport.from]) {
map[autoImport.from] = new Set()
}
map[autoImport.from].add(
autoImport.name === autoImport.as
? autoImport.name
: autoImport.name + aliasKeyword + autoImport.as
)
}
if (isCJS) {
return Object.entries(map)
.map(([name, imports]) => `const { ${Array.from(imports).join(', ')} } = require('${name}');`)
.join('\n')
} else {
return Object.entries(map)
.map(([name, imports]) => `import { ${Array.from(imports).join(', ')} } from '${name}';`)
.join('\n')
}
}
export function filterInPlace<T> (arr: T[], predicate: (v: T) => any) {
let i = arr.length
while (i--) {
if (!predicate(arr[i])) {
arr.splice(i, 1)
}
}
}

View File

@ -1,5 +1,6 @@
import type { AutoImport } from '@nuxt/kit'
import { expect } from 'chai'
import { AutoImportContext, updateAutoImportContext } from '../src/auto-imports/context'
import { TransformPlugin } from '../src/auto-imports/transform'
describe('auto-imports:transform', () => {
@ -8,7 +9,10 @@ describe('auto-imports:transform', () => {
{ name: 'computed', as: 'computed', from: 'bar' }
]
const transformPlugin = TransformPlugin.raw(autoImports, { framework: 'rollup' })
const ctx = { autoImports, map: new Map() } as AutoImportContext
updateAutoImportContext(ctx)
const transformPlugin = TransformPlugin.raw(ctx, { framework: 'rollup' })
const transform = (code: string) => transformPlugin.transform.call({ error: null, warn: null }, code, '')
it('should correct inject', async () => {

View File

@ -9278,6 +9278,14 @@ __metadata:
languageName: unknown
linkType: soft
"example-with-composables@workspace:examples/with-composables":
version: 0.0.0-use.local
resolution: "example-with-composables@workspace:examples/with-composables"
dependencies:
nuxt3: latest
languageName: unknown
linkType: soft
"example-with-layouts@workspace:examples/with-layouts":
version: 0.0.0-use.local
resolution: "example-with-layouts@workspace:examples/with-layouts"