乐趣区

关于前端:Vue组件库实现按需引入可以这么做

本文为 Varlet 组件库源码主题浏览系列第七篇,读完本篇,能够理解到如何通过 unplugin-vue-components 插件来为你的组件库实现按需引入。

手动引入

后面的文章中咱们介绍过 Varlet 组件库的打包流程,最初会打包成几种格局,其中 modulecommonjs格局都不会把所有组件的代码打包到同一个文件,而是保留着各个组件的独立,每个组件都导出作为一个 Vue 插件。

第一种按需应用的办法是咱们手动导入某个组件并进行注册:

import {createApp} from 'vue'
import {Button} from '@varlet/ui'
import '@varlet/ui/es/button/style/index.js'

createApp().use(Button)

Button组件并不是从它的本身目录中引入的,而是从一个对立的入口,@varlet/ui包的 package.json 中配置了两个导出入口:

按需引入,也能够了解成是 tree shaking,它依赖于ES6 模块,因为 ESM 模块语法是动态的,和运行时无关,只能顶层呈现,这就能够只剖析导入和导出,不运行代码即可晓得模块导出的哪些被应用了哪些没有,没有用到的就能够被删除。

所以想要反对按需引入那么必然应用的是 module 入口,这个字段目前各种构建工具应该都是反对的,module入口它是个对立的入口,这个文件中显然导出了所有组件,那么比方咱们只导出 Button 组件,其余没有用到的组件最终是不是不会被打包进去呢,实际上并没有这么简略,因为有的文件它会存在副作用,比方批改了原型链、设置了全局变量等,所以尽管没有显式的被应用,然而只有引入了该文件,副作用就失效了,所以不能被删除,要解决这个问题须要在 package.json 中再配置一个 sideEffects 字段,指明哪些文件是存在副作用的,没有指明的就是没有副作用的,那么构建工具就能够释怀的删除了:

能够看到 Varlet 通知了构建工具,这些款式文件是有副作用的,不要给删除了,其余文件中没有用到的模块能够纵情删除。

主动引入

如果你感觉后面的手动引入比拟麻烦,Varlet也反对主动引入,这个实现依赖的是 unplugin-vue-components 插件,这个插件会扫描所有申明在模板中的组件,而后主动引入 组件逻辑 款式文件 注册组件

Vite 中的配置形式:

import vue from '@vitejs/plugin-vue'
import components from 'unplugin-vue-components/vite'
import {VarletUIResolver} from 'unplugin-vue-components/resolvers'
import {defineConfig} from 'vite'

export default defineConfig({
  plugins: [vue(),
    components({resolvers: [VarletUIResolver()]
    })
  ]
})

如果想要这个插件反对你的组件库,须要编写一个解析器,也就是相似下面的VarletUIResolver,如果想要给更多人用就须要提个pr,这个插件目前曾经反对如下这些风行的组件库:

VarletUIResolver 为例来看一下这个解析器都做了什么:

// unplugin-vue-components/src/core/resolvers/varlet-ui.ts
const varDirectives = ['Ripple', 'Lazy']

export function VarletUIResolver(options: VarletUIResolverOptions = {}): ComponentResolver[] {
  return [
    {
      type: 'component',
      resolve: (name: string) => {const { autoImport = false} = options

        if (autoImport && varFunctions.includes(name))
          return getResolved(name, options)

        if (name.startsWith('Var'))
          return getResolved(name.slice(3), options)
      },
    },
    {
      type: 'directive',
      resolve: (name: string) => {const { directives = true} = options

        if (!directives)
          return

        if (!varDirectives.includes(name))
          return

        return getResolved(name, options)
      },
    },
  ]
}

执行 VarletUIResolver 办法会返回一个数组,unplugin-vue-components反对主动导入组件和指令,所以能够看到下面返回了两种解析办法,尽管目前咱们没有看到 unplugin-vue-components 的源码,然而咱们能够猜测 unplugin-vue-components 在模板中扫描到某个组件时会调用 typecomponentresolve,扫描到指令时会调用typedirectiveresolve,如果解析胜利,那么就会主动增加导入语句。

当扫描到的组件名以 Var 结尾或扫描到 Varlet 的指令时,两个解析办法最初都会调用 getResolved 办法:

// unplugin-vue-components/src/core/resolvers/varlet-ui.ts
export function getResolved(name: string, options: VarletUIResolverOptions): ComponentResolveResult {
    const {
        importStyle = 'css',
        importCss = true,
        importLess,
        autoImport = false,
        version = 'vue3',
    } = options
    // 默认是 vue3 版本
    const path = version === 'vue2' ? '@varlet-vue2/ui' : '@varlet/ui'
    const sideEffects = []
    // 款式导入文件
    if (importStyle || importCss) {if (importStyle === 'less' || importLess)
            sideEffects.push(`${path}/es/${kebabCase(name)}/style/less.js`)
        else
            sideEffects.push(`${path}/es/${kebabCase(name)}/style`)
    }
    return {
        from: path,
        name: autoImport ? name : `_${name}Component`,
        sideEffects,
    }
}

函数的返回值是一个对象,蕴含三个属性:组件的导入门路、导入名称、以及一个副作用列表,外面是组件的款式导入文件。

你可能会对组件的导入名称格局 _${name}Component 有点纳闷,看一下 Varlet 的导出形式,以 Button 组件为例,它的导出文件如下:

默认导出了组件之外还通过 _ButtonComponent 名称又导出了一份,而后看看 @varlet/ui 整体的导出文件:

import Button, * as ButtonModule from './button'

export const _ButtonComponent = ButtonModule._ButtonComponent || {}

function install(app) {Button.install && app.use(Button)
}

export {
  install,
  Button,
}

export default {
  install,
  Button,
}

所以 _${name}Component 格局导出的就是ButtonModule._ButtonComponent,为什么要这么做呢,为什么不间接从:

export {
  install,
  Button,
}

中导入 Button 呢,按理说应该也是能够的,其实是因为 Varlet 有些组件默认的导出不是组件自身,比方ActionSheet

默认导出的是一个函数,基本不是组件自身,那么显然不能间接在模板中应用。

接下来以在 Vite 中的应用为例来大抵看一下 unplugin-vue-components 的实现原理。

浅析 unplugin-vue-components

import components from 'unplugin-vue-components/vite'导入的 componentscreateUnplugin办法执行的返回值:

import {createUnplugin} from 'unplugin'

export default createUnplugin<Options>((options = {}) => {
    const filter = createFilter(options.include || [/\.vue$/, /\.vue\?vue/, /\.vue\?v=/],
        options.exclude || [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]\.nuxt[\\/]/],
    )
    const ctx: Context = new Context(options)
    const api: PublicPluginAPI = {async findComponent(name, filename) {return await ctx.findComponent(name, 'component', filename ? [filename] : [])
        },
        stringifyImport(info) {return stringifyComponentImport(info, ctx)
        },
    }
    return {
        api,

        transformInclude(id) {return filter(id)
        },

        async transform(code, id) {if (!shouldTransform(code))
                return null
            try {const result = await ctx.transform(code, id)
                ctx.generateDeclaration()
                return result
            }
            catch (e) {this.error(e)
            }
        },
        
        //...s
    }
})

unplugin 是一个构建工具的对立插件零碎,也就是写一个插件,反对各种构建工具,目前反对以下这些:

createUnplugin办法接管一个函数为参数,最初会返回一个对象,能够从这个对象中获取用于各个构建工具的插件:

传入的函数会返回一个对象,其中 transformInclude 配置默认只转换 .vue 文件,transform为转换的外围办法,接管 unplugin-vue-components 插件之前的其余插件解决过后的 Vue 文件内容和文件门路作为参数,函数内调用了 ctx.transform 办法,这个办法又调用了 transformer 办法:

export default function transformer(ctx: Context, transformer: SupportedTransformer): Transformer {return async (code, id, path) => {ctx.searchGlob()
    const sfcPath = ctx.normalizePath(path)
    // 用文件内容创立一个魔术字符串
    const s = new MagicString(code)
    // 转换组件
    await transformComponent(code, transformer, s, ctx, sfcPath)
    // 转换指令
    if (ctx.options.directives)
      await transformDirectives(code, transformer, s, ctx, sfcPath)
    // 追加一个正文内容:'/* unplugin-vue-components disabled */'
    s.prepend(DISABLE_COMMENT)
    // 将解决完后的魔术字符串从新转换成一般字符串
    const result: TransformResult = {code: s.toString() }
    if (ctx.sourcemap)
      result.map = s.generateMap({source: id, includeContent: true})
    return result
  }
}

创立了一个 MagicString,而后调用了 transformComponent 办法:

export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {const results = transformer === 'vue2' ? resolveVue2(code, s) : resolveVue3(code, s)
  // ...
}

unplugin-vue-components同时反对 Vue2Vue3,咱们看一下 Vue3 的转换,调用的是 resolveVue3 办法:

const resolveVue3 = (code: string, s: MagicString) => {const results: ResolveResult[] = []

  for (const match of code.matchAll(/_resolveComponent[0-9]*\("(.+?)"\)/g)) {const matchedName = match[1]
    if (match.index != null && matchedName && !matchedName.startsWith('_')) {
      const start = match.index
      const end = start + match[0].length
      results.push({
        rawName: matchedName,
        replace: resolved => s.overwrite(start, end, resolved),
      })
    }
  }

  return results
}

咱们应用 Vue3 官网的在线 playground 来看一下 Vue 单文件的编译后果,如果咱们没有导入组件就在模板中援用组件,那么编译后果如下:

能够看到编译后的 setup 函数返回的渲染函数中生成了 const _component_MyComp = _resolveComponent("MyComp") 这行代码用来解析组件,那么后面的 resolveVue3 办法里的正则 /_resolveComponent[0-9]*\("(.+?)"\)/g 的含意就很显著了,就是用来匹配这个解析语句,参数就是组件的名称,所以通过这个正则会找出所有援用的组件,并返回一个对应的替换办法,回到 transformComponent 办法:

export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {
  // ...
  for (const { rawName, replace} of results) {const name = pascalCase(rawName)
    ctx.updateUsageMap(sfcPath, [name])
    const component = await ctx.findComponent(name, 'component', [sfcPath])
    // ...
  }
}

遍历模板引入的所有组件,调用了 ctx.findComponent 办法:

async findComponent(name: string, type: 'component' | 'directive', excludePaths: string[] = []): Promise<ComponentInfo | undefined> {
    // custom resolvers
    for (const resolver of this.options.resolvers) {if (resolver.type !== type)
        continue

      const result = await resolver.resolve(type === 'directive' ? name.slice(DIRECTIVE_IMPORT_PREFIX.length) : name)
      if (!result)
        continue

      if (typeof result === 'string') {
        info = {
          as: name,
          from: result,
        }
      }
      else {
        info = {
          as: name,
          ...normalizeComponetInfo(result),
        }
      }
      if (type === 'component')
        this.addCustomComponents(info)
      else if (type === 'directive')
        this.addCustomDirectives(info)
      return info
    }

    return undefined
  }

这个办法里就会调用组件库自定义的解析器,如果某个组件被胜利解析到了,那么会将后果保存起来并返回。

回到 transformComponent 办法:

export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {
  // ...
  let no = 0
  for (const { rawName, replace} of results) {
    // ...
    if (component) {const varName = `__unplugin_components_${no}`
      s.prepend(`${stringifyComponentImport({ ...component, as: varName}, ctx)};\n`)
      no += 1
      replace(varName)
    }
  }
}

组件如果被解析到了,那么会调用 stringifyComponentImport 办法创立导入语句并追加到文件内容的结尾,留神组件的导入名称被命名成了 __unplugin_components_${no} 格局,为什么不间接应用组件本来的名字呢,笔者也不分明,可能是为了避免用户本人又导入了组件导致反复吧:

export function stringifyComponentImport({as: name, from: path, name: importName, sideEffects}: ComponentInfo, ctx: Context) {path = getTransformedPath(path, ctx.options.importPathTransform)

  const imports = [stringifyImport({ as: name, from: path, name: importName}),
  ]

  if (sideEffects)
    toArray(sideEffects).forEach(i => imports.push(stringifyImport(i)))

  return imports.join(';')
}

export function stringifyImport(info: ImportInfo | string) {if (typeof info === 'string')
    return `import '${info}'`
  if (!info.as)
    return `import '${info.from}'`
  else if (info.name)
    return `import {${info.name} as ${info.as} } from '${info.from}'`
  else
    return `import ${info.as} from '${info.from}'`
}

这个办法会依据 info 的类型拼接导入语句,VarletUIResolver解析器最初返回的是 fromnamesideEffects 三个字段,所以调用 stringifyImport 办法时会走第三个分支,以后面截图中的为例,后果如下:

import {MyComp as __unplugin_components_0} from 'xxx'
import {MyComp2 as __unplugin_components_1} from 'xxx'

另外也能够看到副作用列表 sideEffects 也被导入了,实际上就是组件的款式导入文件。

transformComponent办法最初调用 replace(varName) 办法将 /_resolveComponent[0-9]*\("(.+?)"\)/ 匹配到的内容改成了__unplugin_components_${no},还是后面截图中的为例:

const _component_MyComp = _resolveComponent("MyComp")
const _component_MyComp2 = _resolveComponent("MyComp2")

被改成了:

const _component_MyComp = __unplugin_components_0
const _component_MyComp2 = __unplugin_components_1

到这里 Vue3 组件的导入语句就增加实现了,也能失常传递到渲染函数中进行应用,Vue2的转换和指令的转换其实也大同小异,有趣味的能够自行浏览源码。

对于组件库的按需引入笔者之前还独自写过一篇,有趣味的也能够看一下:浅析组件库实现按需引入的几种形式。

退出移动版