整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Vue3组件库打包指南,一次生成esm、esm-bu

Vue3组件库打包指南,一次生成esm、esm-bundle、commonjs、umd

文为Varlet组件库源码主题阅读系列第二篇,读完本篇,你可以了解到如何将一个Vue3组件库打包成各种格式

上一篇里提到了启动服务前会先进行一下组件库的打包,运行的命令为:

varlet-cli compile

显然是varlet-cli提供的一个命令:

处理函数为compile,接下来我们详细看一下这个函数都做了什么。

// varlet-cli/src/commands/compile.ts
export async function compile(cmd: { noUmd: boolean }) {
    process.env.NODE_ENV='compile'
    await removeDir()
    // ...
}

// varlet-cli/src/commands/compile.ts
export function removeDir() {
    // ES_DIR:varlet-ui/es
    // LIB_DIR:varlet-ui/lib
    // HL_DIR:varlet-ui/highlight
    // UMD_DIR:varlet-ui/umd
    return Promise.all([remove(ES_DIR), remove(LIB_DIR), remove(HL_DIR), remove(UMD_DIR)])
}

首先设置了一下当前的环境变量,然后清空相关的输出目录。

// varlet-cli/src/commands/compile.ts
export async function compile(cmd: { noUmd: boolean }) {
    // ...
    process.env.TARGET_MODULE='module'
    await runTask('module', compileModule)

    process.env.TARGET_MODULE='esm-bundle'
    await runTask('esm bundle', ()=> compileModule('esm-bundle'))

    process.env.TARGET_MODULE='commonjs'
    await runTask('commonjs', ()=> compileModule('commonjs'))

    process.env.TARGET_MODULE='umd'
    !cmd.noUmd && (await runTask('umd', ()=> compileModule('umd')))
}

接下来依次打包了四种类型的产物,方法都是同一个compileModule,这个方法后面会详细分析。

组件的基本组成

以Button组件为例看一下未打包前的组件结构:

一个典型组件的构成主要是四个文件:

.less:样式

.vue:组件

index.ts:导出组件,提供组件注册方法

props.ts:组件的props定义

样式部分Varlet使用的是less语言,样式比较少的话会直接内联写到Vue单文件的style块中,否则会单独创建一个样式文件,比如图中的button.less,每个组件除了引入自己本身的样式外,还会引入一些基本样式、其他组件的样式:

index.ts文件用来导出组件,提供组件的注册方法:

props.ts文件用来声明组件的props类型:

有的组件没有使用.vue,而是.tsx,也有些组件会存在其他文件,比如有些组件就还存在一个provide.ts文件,用于向子孙组件注入数据。

打包的整体流程

首先大致过一遍整体的打包流程,主要函数为compileModule:

// varlet-cli/src/compiler/compileModule.ts
export async function compileModule(modules: 'umd' | 'commonjs' | 'esm-bundle' | boolean=false) {
  if (modules==='umd') {
    // 打包umd格式
    await compileUMD()
    return
  }

  if (modules==='esm-bundle') {
    // 打包esm-bundle格式
    await compileESMBundle()
    return
  }
    
  // 打包commonjs和module格式
  // 打包前设置一下环境变量
  process.env.BABEL_MODULE=modules==='commonjs' ? 'commonjs' : 'module'
  // 输出目录
  // ES_DIR:varlet-ui/es
  // LIB_DIR:varlet-ui/lib
  const dest=modules==='commonjs' ? LIB_DIR : ES_DIR
  // SRC_DIR:varlet-ui/src,直接将组件的源码目录复制到输出目录
  await copy(SRC_DIR, dest)
  // 读取输出目录
  const moduleDir: string[]=await readdir(dest)
  // 遍历打包每个组件
  await Promise.all(
    // 遍历每个组件目录
    moduleDir.map((filename: string)=> {
      const file: string=resolve(dest, filename)
      if (isDir(file)) {
        // 在每个组件目录下新建两个样式入口文件
        ensureFileSync(resolve(file, './style/index.js'))
        ensureFileSync(resolve(file, './style/less.js'))
      }
      // 打包组件
      return isDir(file) ? compileDir(file) : null
    })
  )
  // 遍历varlet-ui/src/目录,找出所有存在['index.vue', 'index.tsx', 'index.ts', 'index.jsx', 'index.js']这些文件之一的目录
  const publicDirs=await getPublicDirs()
  // 生成整体的入口文件
  await (modules==='commonjs' ? compileCommonJSEntry(dest, publicDirs) : compileESEntry(dest, publicDirs))
}

umd和esm-bundle两种格式都会把所有内容都打包到一个文件,用的是Vite提供的方法进行打包。

commonjs和module是单独打包每个组件,不会把所有组件的内容都打包到一起,Vite没有提供这个能力,所以需要自行处理,具体操作为:

  • 先把组件源码目录varlet/src/下的所有组件文件都复制到对应的输出目录下;
  • 然后在输出目录遍历每个组件目录: 创建两个样式的导出文件;删除不需要的目录、文件(测试、示例、文档);分别编译Vue单文件、ts文件、less文件;
  • 全部打包完成后,遍历所有组件,动态生成整体的导出文件;

以compileESEntry方法为例看一下整体导出文件的生成:

// varlet-cli/src/compiler/compileScript.ts
export async function compileESEntry(dir: string, publicDirs: string[]) {
  const imports: string[]=[]
  const plugins: string[]=[]
  const constInternalComponents: string[]=[]
  const cssImports: string[]=[]
  const lessImports: string[]=[]
  const publicComponents: string[]=[]
  // 遍历组件目录名称
  publicDirs.forEach((dirname: string)=> {
    // 连字符转驼峰式
    const publicComponent=bigCamelize(dirname)
 // 收集组件名称
    publicComponents.push(publicComponent)
    // 收集组件导入语句
    imports.push(`import ${publicComponent}, * as ${publicComponent}Module from './${dirname}'`)
    // 收集内部组件导入语句
    constInternalComponents.push(
      `export const _${publicComponent}Component=${publicComponent}Module._${publicComponent}Component || {}`
    )
    // 收集插件注册语句
    plugins.push(`${publicComponent}.install && app.use(${publicComponent})`)
    // 收集样式导入语句
    cssImports.push(`import './${dirname}/style'`)
    lessImports.push(`import './${dirname}/style/less'`)
  })

  // 拼接组件注册方法
  const install=`
function install(app) {
  ${plugins.join('\n  ')}
}
`

  // 拼接导出入口index.js文件的内容,注意它是不包含样式的
  const indexTemplate=`\
${imports.join('\n')}\n
${constInternalComponents.join('\n')}\n
${install}
export {
  install,
  ${publicComponents.join(',\n  ')}
}

export default {
  install,
  ${publicComponents.join(',\n  ')}
}
`
  
  // 拼接css导入语句
  const styleTemplate=`\
${cssImports.join('\n')}
`

  // 拼接umdIndex.js文件,这个文件是用于后续打包umd和esm-bundle格式时作为打包入口,注意它是包含样式导入语句的
  const umdTemplate=`\
${imports.join('\n')}\n
${cssImports.join('\n')}\n
${install}
export {
  install,
  ${publicComponents.join(',\n  ')}
}

export default {
  install,
  ${publicComponents.join(',\n  ')}
}
`

  // 拼接less导入语句
  const lessTemplate=`\
${lessImports.join('\n')}
`
  // 将拼接的内容写入到对应文件
  await Promise.all([
    writeFile(resolve(dir, 'index.js'), indexTemplate, 'utf-8'),
    writeFile(resolve(dir, 'umdIndex.js'), umdTemplate, 'utf-8'),
    writeFile(resolve(dir, 'style.js'), styleTemplate, 'utf-8'),
    writeFile(resolve(dir, 'less.js'), lessTemplate, 'utf-8'),
  ])
}

打包成module和commonjs格式

打包成umd和esm-bundle两种格式依赖module格式的打包产物,而打包成module和commonjs两种格式是同一套逻辑,所以我们先来看看是如何打包成这两种格式的。

这两种格式就是单独打包每个组件,生成单独的入口文件和样式文件,然后再生成一个统一的导出入口,不会把所有组件的内容都打包到同一个文件,方便按需引入,去除不需要的内容,减少文件体积。

打包每个组件的compileDir方法:

// varlet-cli/src/compiler/compileModule.ts
export async function compileDir(dir: string) {
  // 读取组件目录
  const dirs=await readdir(dir)
  // 遍历组件目录下的文件
  await Promise.all(
    dirs.map((filename)=> {
      const file=resolve(dir, filename)
      // 删除组件目录下的__test__目录、example目录、docs目录
      ;[TESTS_DIR_NAME, EXAMPLE_DIR_NAME, DOCS_DIR_NAME].includes(filename) && removeSync(file)
   // 如果是.d.ts文件或者是style目录(前面为样式入口文件创建的目录)直接返回
      if (isDTS(file) || filename===STYLE_DIR_NAME) {
        return Promise.resolve()
      }
   // 编译文件
      return compileFile(file)
    })
  )
}

删除了不需要的目录,然后针对需要编译的文件调用了compileFile方法:

// varlet-cli/src/compiler/compileModule.ts
export async function compileFile(file: string) {
  isSFC(file) && (await compileSFC(file))// 编译vue文件
  isScript(file) && (await compileScriptFile(file))// 编译js文件
  isLess(file) && (await compileLess(file))// 编译less文件
  isDir(file) && (await compileDir(file))// 如果是目录则进行递归
}

分别处理三种文件,让我们一一来看。

编译Vue单文件

// varlet-cli/src/compiler/compileSFC.ts
import { parse } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // 读取Vue单文件内容
    const sources: string=await readFile(sfc, 'utf-8')
    // 使用@vue/compiler-sfc包解析单文件
    const { descriptor }=parse(sources, { sourceMap: false })
    // 取出单文件的每部分内容
    const { script, scriptSetup, template, styles }=descriptor
    // Varlet暂时不支持setup语法
    if (scriptSetup) {
        logger.warning(
            `\n Varlet Cli does not support compiling script setup syntax\
\n  The error in ${sfc}`
        )
        return
    }
    // ...
}

使用@vue/compiler-sfc包来解析Vue单文件,parse方法可以解析出Vue单文件中的各个块,针对各个块,@vue/compiler-sfc包都提供了相应的编译方法,后续都会涉及到。

// varlet-cli/src/compiler/compileSFC.ts
import hash from 'hash-sum'

export async function compileSFC(sfc: string) {
    // ...
    // scoped
    // 检查是否存在scoped作用域的样式块
    const hasScope=styles.some((style)=> style.scoped)
    // 将单文件的内容进行hash生成id
    const id=hash(sources)
    // 生成样式的scopeId
    const scopeId=hasScope ? `data-v-${id}` : ''
    // ...
}

这一步主要是检查style块是否存在作用域块,存在的话会生成一个作用域id,作为css的作用域,防止和其他样式冲突,这两个id相关的编译方法需要用到。

// varlet-cli/src/compiler/compileSFC.ts
import { compileTemplate } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // ...
    if (script) {
        // template
        // 编译模板为渲染函数
        const render=      template &&
              compileTemplate({
                  id,
                  source: template.content,
                  filename: sfc,
                  compilerOptions: {
                      scopeId,
                  },
              })
  // 注入render函数
        let { content }=script
        if (render) {
            const { code }=render
            content=injectRender(content, code)
        }
        // ...
    }
}

使用@vue/compiler-sfc包的compileTemplate方法将解析出的模板部分编译为渲染函数,然后调用injectRender方法将渲染函数注入到script中:

// varlet-cli/src/compiler/compileSFC.ts
const NORMAL_EXPORT_START_RE=/export\s+default\s+{/
const DEFINE_EXPORT_START_RE=/export\s+default\s+defineComponent\s*\(\s*{/

export function injectRender(script: string, render: string): string {
  if (DEFINE_EXPORT_START_RE.test(script.trim())) {
    return script.trim().replace(
      DEFINE_EXPORT_START_RE,
      `${render}\nexport default defineComponent({
  render,\
    `
    )
  }
  if (NORMAL_EXPORT_START_RE.test(script.trim())) {
    return script.trim().replace(
      NORMAL_EXPORT_START_RE,
      `${render}\nexport default {
  render,\
    `
    )
  }
  return script
}

兼容两种导出方式,以一个小例子来看一下,比如生成的渲染函数为:

export function render(_ctx, _cache) {
    // ...
}

script的内容为:

export default defineComponent({
    name: 'VarButton',
    // ...
})

注入render后script的内容变成了:

export function render(_ctx, _cache) {
    // ...
}
export default defineComponent({
    render,
    name: 'VarButton',
    /// ...
})

其实就是把渲染函数的内容和script的内容合并了,script其实就是组件的选项对象,所以同时也把组件的渲染函数添加到组件对象上。

继续compileSFC方法:

// varlet-cli/src/compiler/compileSFC.ts
import { compileStyle } from '@vue/compiler-sfc'

export async function compileSFC(sfc: string) {
    // ...
    if (script) {
        // ...
        // script
        // 编译js
        await compileScript(content, sfc)
        // style
        // 编译样式
        for (let index=0; index < styles.length; index++) {
          const style: SFCStyleBlock=styles[index]
          // replaceExt方法接收文件名称,比如xxx.vue,然后使用第二个参数替换文件名称的扩展名,比如处理完会返回xxxSfc.less
          const file=replaceExt(sfc, `Sfc${index || ''}.${style.lang || 'css'}`)
          // 编译样式块
          let { code }=compileStyle({
            source: style.content,
            filename: file,
            id: scopeId,
            scoped: style.scoped,
          })
          // 去除样式中的导入语句
          code=extractStyleDependencies(file, code, STYLE_IMPORT_RE, style.lang as 'css' | 'less', true)
          // 将解析后的样式写入文件
          writeFileSync(file, clearEmptyLine(code), 'utf-8')
          // 如果样式块是less语言,那么同时也编译成css文件
          style.lang==='less' && (await compileLess(file))
        }
    }
}

调用了compileScript方法编译script内容,这个方法我们下一小节再说。然后遍历style块,每个块都会生成相应的样式文件,比如Button.vue组件存在一个less语言的style块

那么会生成一个ButtonSfc.less,因为是less,所以同时也会再编译生成一个ButtonSfc.css文件,当然这两个样式文件里只包括内联在Vue单文件中的样式,不包括使用@import导入的样式,所以生成的这两个样式文件都是空的:

编译样式块使用的是@vue/compiler-sfc的compileStyle方法,它会帮我们处理<style scoped>, <style module>以及css变量注入的问题。

extractStyleDependencies方法会提取并去除样式中的导入语句:

// varlet-cli/src/compiler/compileStyle.ts
import { parse, resolve } from 'path'

export function extractStyleDependencies(
  file: string,
  code: string,
  reg: RegExp,//     /@import\s+['"](.+)['"]\s*;/g
  expect: 'css' | 'less',
  self: boolean
) {
  const { dir, base }=parse(file)
  // 用正则匹配出样式导入语句
  const styleImports=code.match(reg) ?? []
  // 这两个文件是之前创建的
  const cssFile=resolve(dir, './style/index.js')
  const lessFile=resolve(dir, './style/less.js')
  const modules=process.env.BABEL_MODULE
  // 遍历导入语句
  styleImports.forEach((styleImport: string)=> {
    // 去除导入源的扩展名及处理导入的路径,因为index.js和less.js两个文件和Vue单文件不在同一个层级,所以导入的相对路径需要修改一下
    const normalizedPath=normalizeStyleDependency(styleImport, reg)
    // 将导入语句写入创建的两个文件中
    smartAppendFileSync(
      cssFile,
      modules==='commonjs' ? `require('${normalizedPath}.css')\n` : `import '${normalizedPath}.css'\n`
    )
    smartAppendFileSync(
      lessFile,
      modules==='commonjs' ? `require('${normalizedPath}.${expect}')\n` : `import '${normalizedPath}.${expect}'\n`
    )
  })
  // 上面已经把Vue单文件中style块内的导入语句提取出去了,另外之前也提到了每个style块本身也会创建一个样式文件,所以导入这个文件的语句也需要追加进去:
  if (self) {
    smartAppendFileSync(
      cssFile,
      modules==='commonjs'
        ? `require('${normalizeStyleDependency(base, reg)}.css')\n`
        : `import '${normalizeStyleDependency(base, reg)}.css'\n`
    )
    smartAppendFileSync(
      lessFile,
      modules==='commonjs'
        ? `require('${normalizeStyleDependency(base, reg)}.${expect}')\n`
        : `import '${normalizeStyleDependency(base, reg)}.${expect}'\n`
    )
  }
  // 去除样式中的导入语句
  return code.replace(reg, '')
}

到这里,一共生成了四个文件:

编译less文件

script部分的编译比较复杂,我们最后再看,先看一下less文件的处理。

// varlet-cli/src/compiler/compileStyle.ts
import { render } from 'less'

export async function compileLess(file: string) {
  const source=readFileSync(file, 'utf-8')
  const { css }=await render(source, { filename: file })

  writeFileSync(replaceExt(file, '.css'), clearEmptyLine(css), 'utf-8')
}

很简单,使用less包将less编译成css,然后写入文件即可,到这里又生成了一个css文件:

编译script文件

script部分,主要是ts、tsx文件,Varlet大部分组件是使用Vue单文件编写的,不过也有少数组件使用的是tsx,编译调用了compileScriptFile方法:

// varlet-cli/src/compiler/compileScript.ts
export async function compileScriptFile(file: string) {
  const sources=readFileSync(file, 'utf-8')

  await compileScript(sources, file)
}

读取文件,然后调用compileScript方法,前面Vue单文件中解析出来的script部分内容调用的也是这个方法。

兼容模块导入

// varlet-cli/src/compiler/compileScript.ts
export async function compileScript(script: string, file: string) {
  const modules=process.env.BABEL_MODULE
  // 兼容模块导入
  if (modules==='commonjs') {
    script=moduleCompatible(script)
  }
  // ...
}

首先针对commonjs做了一下兼容处理:

// varlet-cli/src/compiler/compileScript.ts
export const moduleCompatible=(script: string): string=> {
  const moduleCompatible=get(getVarletConfig(), 'moduleCompatible', {})
  Object.keys(moduleCompatible).forEach((esm)=> {
    const commonjs=moduleCompatible[esm]
    script=script.replace(esm, commonjs)
  })
  return script
}

替换一些导入语句,Varlet组件开发是基于ESM规范的,使用其他库时导入的肯定也是ESM版本,所以编译成commonjs模块时需要修改成对应的commonjs版本,Varlet引入的第三方库不多,主要就是dayjs:

使用babel编译

继续compileScript方法:

// varlet-cli/src/compiler/compileScript.ts
import { transformAsync } from '@babel/core'

export async function compileScript(script: string, file: string) {
  // ...
  // 使用babel编译js
  let { code }=(await transformAsync(script, {
    filename: file,// js内容对应的文件名,babel插件会用到
  })) as BabelFileResult
  // ...
}

接下来使用@babel/core包编译js内容,transformAsync方法会使用本地的配置文件,因为打包命令是在varlet-ui/目录下运行的,所以babel会在这个目录下寻找配置文件:

编译成module还是commonjs格式的判断也在这个配置中,有关配置的详解,有兴趣的可以阅读最后的附录小节。

提取样式导入语句

继续compileScript方法:

// varlet-cli/src/compiler/compileScript.ts
export const REQUIRE_CSS_RE=/(?<!['"`])require\(\s*['"](\.{1,2}\/.+\.css)['"]\s*\);?(?!\s*['"`])/g
export const REQUIRE_LESS_RE=/(?<!['"`])require\(\s*['"](\.{1,2}\/.+\.less)['"]\s*\);?(?!\s*['"`])/g
export const IMPORT_CSS_RE=/(?<!['"`])import\s+['"](\.{1,2}\/.+\.css)['"]\s*;?(?!\s*['"`])/g
export const IMPORT_LESS_RE=/(?<!['"`])import\s+['"](\.{1,2}\/.+\.less)['"]\s*;?(?!\s*['"`])/g

export async function compileScript(script: string, file: string) {
    // ...
    code=extractStyleDependencies(
        file,
        code as string,
        modules==='commonjs' ? REQUIRE_CSS_RE : IMPORT_CSS_RE,
        'css'
    )
    code=extractStyleDependencies(
        file,
        code as string,
        modules==='commonjs' ? REQUIRE_LESS_RE : IMPORT_LESS_RE,
        'less'
    )
    // ...
}

extractStyleDependencies方法前面已经介绍了,所以这一步的操作就是提取并去除script内的样式导入语句。

转换其他导入语句

// varlet-cli/src/compiler/compileScript.ts
export async function compileScript(script: string, file: string) {
    // ...
    code=replaceVueExt(code as string)
    code=replaceTSXExt(code as string)
    code=replaceJSXExt(code as string)
    code=replaceTSExt(code as string)
    // ...
}

这一步的操作是把script中的各种类型的导入语句都修改为导入.js文件,因为这些文件最后都会被编译成js文件,比如button/index.ts文件内导入了Button.vue组件:

import Button from './Button.vue'
// ...

转换后会变成:

import Button from './Button.js'
// ...

继续:

// varlet-cli/src/compiler/compileScript.ts
export async function compileScript(script: string, file: string) {
    // ...
    removeSync(file)
   writeFileSync(replaceExt(file, '.js'), code, 'utf8')
}

最后就是把处理完的script内容写入文件。

到这里.vue,.ts、.tsx文件都已处理完毕:

小节

到这里,打包成module和commonjs格式就完成了,总结一下所做的事情:

  • less文件直接使用less包编译成同名的css文件;
  • ts、tsx等文件使用babel编译成js文件;提取并去除其中的样式导入语句,并将该样式导入语句写入单独的文件、修改.vue、.ts等类型的导入语句为对应的编译后的js;
  • Vue单文件使用@vue/compiler-sfc解析并对各个块分别使用对应的函数进行编译;每个style块也会提取并去除其中的样式导入语句,并将该导入语句写入单独的文件,剩下的样式内容会分别创建一个对应的样式文件,如果是less块,同时会编译并创建一个同名的css文件;template的编译结果会合并到script内,然后script的内容会重复上一步ts文件的处理逻辑;
  • 所有组件都编译完了,再动态创建整体的导出文件,一共生成了四个文件:

打包成esm-bundle

打包成esm-bundle格式调用的是compileESMBundle方法:

// varlet-cli/src/compiler/compileModule.ts
import { build } from 'vite'

export function compileESMBundle() {
  return new Promise<void>((resolve, reject)=> {
    const config=getESMBundleConfig(getVarletConfig())

    build(config)
      .then(()=> resolve())
      .catch(reject)
  })
}

getVarletConfig方法会把varlet-cli/varlet.default.config.js和varlet-ui/varlet.config.js两个配置进行合并,看一下getESMBundleConfig方法:

// varlet-cli/src/config/vite.config.js
export function getESMBundleConfig(varletConfig: Record<string, any>): InlineConfig {
  const name=get(varletConfig, 'name')// name默认为Varlet
  const fileName=`${kebabCase(name)}.esm.js`// 输出文件名,varlet.esm.js

  return {
    logLevel: 'silent',
    build: {
      emptyOutDir: true,// 清空输出目录
      lib: {// 指定构建为库
        name,// 库暴露的全局变量
        formats: ['es'],// 构建格式
        fileName: ()=> fileName,// 打包出口
        entry: resolve(ES_DIR, 'umdIndex.js'),// 打包入口
      },
      rollupOptions: {// 传给rollup的配置
        external: ['vue'],// 外部化处理不需要打包进库的依赖
        output: {
          dir: ES_DIR,// 输出目录,ES_DIR:varlet-ui/es
          exports: 'named',// 既存在命名导出,也存在默认导出,所以设置为named,详情:https://rollupjs.org/guide/en/#outputexports
          globals: {// 在umd构建模式下为外部化的依赖提供一个全局变量
            vue: 'Vue',
          },
        },
      },
    },
    plugins: [clear()],
  }
}

其实就是使用如上的配置来调用Vite的build方法进行打包,可参考库模式,可以看到打包入口为前面打包module格式时生成的umdIndex.js文件。

因为Vite开发环境使用的是esbuild,生产环境打包使用的是rollup,所以想要深入玩转Vite,这几个东西都需要了解,包括各自的配置选项、插件开发等,还是不容易的。

打包完成后会在varlet-ui/es/目录下生成两个文件:

打包成umd格式

打包成umd格式调用的是compileUMD方法:

// varlet-cli/src/compiler/compileModule.ts
import { build } from 'vite'

export function compileUMD() {
  return new Promise<void>((resolve, reject)=> {
    const config=getUMDConfig(getVarletConfig())

    build(config)
      .then(()=> resolve())
      .catch(reject)
  })
}

整体和打包esm-bundle是一样的,只不过获取的配置不一样:

// varlet-cli/src/config/vite.config.js
export function getUMDConfig(varletConfig: Record<string, any>): InlineConfig {
  const name=get(varletConfig, 'name')// name默认为Varlet
  const fileName=`${kebabCase(name)}.js`// 将驼峰式转换成-连接

  return {
    logLevel: 'silent',
    build: {
      emptyOutDir: true,
      lib: {
        name,
        formats: ['umd'],// 设置为umd
        fileName: ()=> fileName,
        entry: resolve(ES_DIR, 'umdIndex.js'),// ES_DIR:varlet-ui/es,打包入口
      },
      rollupOptions: {
        external: ['vue'],
        output: {
          dir: UMD_DIR,// 输出目录,UMD_DIR:varlet-ui/umd
          exports: 'named',
          globals: {
            vue: 'Vue',
          },
        },
      },
    },
    // 使用了两个插件,作用如其名
    plugins: [inlineCSS(fileName, UMD_DIR), clear()],
  }
}

大部分配置是一样的,打包入口同样也是varlet-ui/es/umdIndex.js,打包结果会在varlet-ui/umd/目录下生成一个varlet.js文件,Varlet和其他组件库稍微有点不一样的地方是它把样式也都打包进了js文件,省去了使用时需要再额外引入样式文件的麻烦,这个操作是inlineCSS插件做的,这个插件也是Varlet自己编写的,代码也很简单:

// varlet-cli/src/config/vite.config.js
function inlineCSS(fileName: string, dir: string): PluginOption {
  return {
    name: 'varlet-inline-css-vite-plugin',// 插件名称
    apply: 'build',// 设置插件只在构建时被调用
    closeBundle() {// rollup钩子,打包完成后调用的钩子
      const cssFile=resolve(dir, 'style.css')
      if (!pathExistsSync(cssFile)) {
        return
      }
      const jsFile=resolve(dir, fileName)
      const cssCode=readFileSync(cssFile, 'utf-8')
      const jsCode=readFileSync(jsFile, 'utf-8')
      const injectCode=`;(function(){var style=document.createElement('style');style.type='text/css';\
style.rel='stylesheet';style.appendChild(document.createTextNode(\`${cssCode.replace(/\\/g, '\\\\')}\`));\
var head=document.querySelector('head');head.appendChild(style)})();`
      // 将【动态将样式插入到页面】的代码插入到js代码内
      writeFileSync(jsFile, `${injectCode}${jsCode}`)
      // 将该样式文件复制到varlet-ui/lib/style.css文件里
      copyFileSync(cssFile, resolve(LIB_DIR, 'style.css'))
      // 删除样式文件
      removeSync(cssFile)
    },
  }
}

这个插件所做的事情就是在打包完成后,读取生成的style.css文件,然后拼接一段js代码,这段代码会把样式动态插入到页面,然后把这段js合并到生成的js文件中,这样就不用自己手动引入样式文件了。

同时,也会把样式文件复制一份到lib目录下,也就是commonjs产物的目录。

最后再回顾一下这个打包顺序:

你会发现这个顺序是有原因的,ems-bundle的打包入口依赖module的产物,umd打包会给commonjs复制一份样式文件,所以打包umd需要在commonjs后面。

附录:babel配置详解

上文编译script、ts、tsx内容使用的是babel,提到了会使用本地的配置文件:

主要就是配置了一个presets,presets即babel的预设,作用是方便使用一些共享配置,可以简单了解为包含了一组插件,babel的转换是通过各种插件进行的,所以使用预设可以免去自己配置插件,可以使用本地的预设,也可以使用发布在npm包里的预设,预设可以传递参数,比如上图,使用的是@varlet/cli包里附带的一个预设:

预设其实就是一个js文件,导出一个函数,这个函数可以接受两个参数,api可以访问babel自身导出的所有模块,同时附带了一些配置文件指定的api,options为使用预设时传入的参数,这个函数需要返回一个对象,这个对象就是具体的配置。

// varlet-cli/src/config/babel.config.ts
module.exports=(api?: ConfigAPI, options: PresetOption={})=> {
  if (api) {
    // 设置不要缓存该配置,每次都执行函数重新获取
    api.cache.never()
  }
  // 判断打包格式
  const isCommonJS=process.env.NODE_ENV==='test' || process.env.BABEL_MODULE==='commonjs'
  return {
    presets: [
      [
        require.resolve('@babel/preset-env'),
        {
          // 编译为commonjs模块类型时需要将ESM模块语法转换成commonjs模块语法,否则保留ESM模块语法
          modules: isCommonJS ? 'commonjs' : false,
          loose: options.loose,// 是否允许@babel/preset-env预设中配置的插件开启松散转换,https://cloud.tencent.com/developer/article/1418101
        },
      ],
      require.resolve('@babel/preset-typescript'),
      require('./babel.sfc.transform'),
    ],
    plugins: [
      [
        require.resolve('@vue/babel-plugin-jsx'),
        {
          enableObjectSlots: options.enableObjectSlots,
        },
      ],
    ],
  }
}
export default module.exports

又配置了三个预设,无限套娃,@babel/preset-env预设是一个智能预设,会根据你的目标环境自动判断需要转换哪些语法,@babel/preset-typescript用来支持ts语法,babel.sfc.transform是varlet自己编写的,用来转换Vue单文件。

还配置了一个babel-plugin-jsx插件,用来在Vue中支持JSX语法。

预设和插件的应用顺序是有规定的:

  • 插件在预设之前运行
  • 多个插件按从第一个到最后一个顺序运行
  • 多个预设按从最后一个到第一个顺序运行

基于此我们可以大致窥探一下整个转换流程,首先运行插件@vue/babel-plugin-jsx转换JSX语法,然后运行预设babel.sfc.transform:

// varlet-cli/src/config/babel.sfc.transform.ts
import { readFileSync } from 'fs'
import { declare } from '@babel/helper-plugin-utils'

module.exports=declare(()=> ({
  overrides: [
    {
      test: (file: string)=> {
        if (/\.vue$/.test(file)) {
          const code=readFileSync(file, 'utf8')
          return code.includes('lang="ts"') || code.includes("lang='ts'")
        }

        return false
      },
      plugins: ['@babel/plugin-transform-typescript'],
    },
  ],
}))

通过babel的overrides选项来根据条件注入配置,当处理的是Vue单文件的内容,并且使用的是ts语法,那么就会注入一个插件@babel/plugin-transform-typescript,用于转换ts语法,非Vue单文件会忽略这个配置,进入下一个preset:@babel/preset-typescript,这个预设也包含了前面的@babel/plugin-transform-typescript插件,但是这个预设只会在.ts文件才会启用ts插件,所以前面才需要自行判断Vue单文件并手动配置ts插件,ts语法转换完毕后最后会进入@babel/preset-env,进行js语法的转换。

到的需求

前段时间需要快速做个静态展示页面,要求是响应式和较美观。由于时间较短,自己动手写的话也有点麻烦,所以就打算上网找现成的。

中途找到了几个页面发现不错,然后就开始思考怎么把页面给下载下来。

由于之前还没有了解过爬虫,自然也就没有想到可以用爬虫来抓取网页内容。所以我采取的办法是:

  1. 打开chrome的控制台,进入Application选项
  2. 找到Frames选项,找到html文件,再右键Save As...
  3. 手动创建本地的js/css/images目录
  4. 依次打开Frames选项下的Images/Scripts/Stylesheets,一个文件就要右键Save As...

这个办法是我当时能想到的最好办法了。不过这种人为的办法有以下缺点:

  1. 手工操作,麻烦费时
  2. 一不小心就忘记保存哪个文件
  3. 难以处理路径之间的关系,比如一张图片a.jpg, 它在html中的引用方式是images/banner/a.jpg,这样我们以后还要手动去解决路径依赖关系

然后刚好前段时间接触了一点python,想到可以写个python爬虫来帮我自动抓取静态网站。于是就马上动手,参考相关资料等等。

记得关注小编后私信【学习】领取Python学习教程哦。

器给我们的生活带来了极大便利,人人都喜欢容器,然而容器也很耗空间,动辄几百兆,上G的镜像是普遍现象。本文我们就学习容器精简的案例,通过一系列的骚操作,最终将镜像的大小从943MB减小到了6.32k。

概述

容器是实践中用来解决与操作软件版本和包依赖相关的所有问题的有效途径。 人人都喜欢容器,但是用容器就得面对各式各样庞大和杂乱的镜像,如果空间有限,则很快就会被充满,实际上可以通过一些有效的策略来减小镜像大小。

基本步骤

一个Http应用容器,可以通过指定端口提供web服务。

不进行卷挂载。

原始方案

为了获得基准镜像大小,我们用node.js创建一个简单只提供index.js访问的简单的服务器:

index.js代码:

const fs=require("fs");
const http=require('http');
const server=http.createServer((req, res)=> {
res.writeHead(200, { 'content-type': 'text/html' })
fs.createReadStream('index.html').pipe(res)
})
server.listen(port, hostname, ()=> {
console.log(`Server: http://0.0.0.0:8080/`);
});

然后,将该文件内置到一个镜像中,镜像基于Node官方基本镜像。

FROM node:14
COPY . .
CMD ["node", "index.js"]

编译

docker build -t cchttp:01 ./

镜像大小为943MB

精简基础镜像

镜像精简最常用,最简单,最明显的策略之一就是使用较小的基础图像。Node镜像中slim 变体(基于debian,但预安装的依赖项较少)和基于Alpine Linux的alpine变体 。

这两个基础镜像分别为node:14-slim 和 node:14-alpine ,其镜像大小分别减少到167MB 和 116MB 分别。

Docker由于镜像是分层叠加的,node.js需要依赖很多层的镜像,除了精简解决方案目前还没有其他变小的方法。

更换语言

为了进一步优化,需要使用运行时依赖项更少的编译语言。而这时候肯定会首先想到的是一个静态编译语言Golang,这是个常见而且不错的选择。在Golang中一个基本的Web服务代码如下:

web.go:

package main
import (
"fmt"
"log"
"net/http"
)
func main() {
fileServer :=http.FileServer(http.Dir("./"))
http.Handle("/", fileServer)
fmt.Printf("Starting server at port 8080\n")
if err :=http.ListenAndServe(":8080", nil); err !=nil {
log.Fatal(err)
}
}

然后用golang官方基础镜像,将其打包到镜像:

FROM golang:1.14
COPY . .
RUN go build -o server .
CMD ["./server"]

基于golang的解决方案,镜像大小818MB,还是很大。

通过分析发现是由于golang基本镜像中安装了很多依赖包,这些依赖包在构建go软件时很有用,但不是每个运行时都需要的,所以可以从这儿着手优化。

多阶段构建

Docker支持多阶段构建的机制,可以很轻松在具有所有必要依赖项的环境中构建代码,然后将生成的可执行包直接打包到其他镜像中使用。这样就可以解决我们上一步遇到需要编译时工具和包,但是运行时不需要包,这样可以极大地减少镜像大小。

注意:Docker多阶段构建的机制是Docker 17.05引入的新特性,如果要使用该功能你需要将Docker版本升级到Docker 17.05及更高版本。

到多阶段构建dockerfile:

###编译###
FROM golang:1.14-alpine AS builder
COPY . .
RUN go build -o server .
###运行###
FROM alpine:3.12
COPY --from=builder /go/server ./server
COPY index.html index.html
CMD ["./server"]

Docker images

(⊙o⊙)哇,策略生效,这样生成的镜像只有13.2MB。

静态编译结合scratch基础镜像

13M的镜像已经很不错了,但是还有其他优化的技巧。在docker世界中还有几个基础镜像scratch ,那就是一个From 0 开始的基础镜像,使用该镜像没有任何依赖,完全从0开始,所以大小也就从0开始。Linux 有个发行版LFS,其全称是Linux From Scratch ,就是从零开始自己动手编译出一个完整的OS。这个scratch基础镜像也是这个意思。

为了让scratch基础镜像支持我们的web.go运行,我们需要在编译镜像中添加静态编译的标志,确保所有依赖都可以打包到运行镜像中:

### 编译###
FROM golang:1.14 as builder
COPY . .
RUN go build -o server \
-ldflags "-linkmode external -extldflags -static" \
-a web.go
###运行###
FROM scratch
COPY --from=builder /go/server ./server
COPY index.html index.html
CMD ["./server"]

上面构建过程中,在代码链接过程中模式设置为external,-static链接外部链接器。

优化后,镜像大小为8.65MB。

最终大杀器——汇编语言

用Golang语言编写的程序,起码也有大概M级别的大小,10MB镜像应该已经到了可以精简的极限。但是还可以用其他技巧来大幅度精简大小,但是需要使用要给终极大杀器,那就是汇编语言,最终解决方案是使用一个汇编编写的全功能http服务器assmttpd,其源码托管在GitHub(github/nemasu/asmttpd)。

我们还使用多阶段编译方法,在ubuntu基础镜像中先编译其依赖项,然后在Scratch基础镜像中打包并运行。

###编译###
FROM ubuntu:18.04 as builder
RUN apt update
RUN apt install -y make yasm as31 nasm binutils
COPY . .
RUN make release
###运行###
FROM scratch
COPY --from=builder /asmttpd /asmttpd
COPY /web_root/index.html /web_root/index.html
CMD ["/asmttpd", "/web_root", "8080"]

产生的图像大小仅为6.34kB:

然后用该镜像运行一个容器:

docker run -it -p 10080:8080 cchttp:07

用curl访问一下:

curl -vv 127.0.0.1:10080

总结

本文我们探索了容器精简的各种方法和尝试。当然由于容器的功能简单,这些策略可能不发直接在实践中使用,但是可以作为容器调优的思路参考。