整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          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

          總結

          本文我們探索了容器精簡的各種方法和嘗試。當然由于容器的功能簡單,這些策略可能不發直接在實踐中使用,但是可以作為容器調優的思路參考。


          主站蜘蛛池模板: 中文字幕日韩一区二区三区不卡| 狠狠爱无码一区二区三区| 波多野结衣一区二区三区aV高清| 久热国产精品视频一区二区三区| 精品一区精品二区制服| 在线成人一区二区| 国产精品一区二区无线| 日本精品少妇一区二区三区| 大伊香蕉精品一区视频在线| 另类免费视频一区二区在线观看 | 日韩AV无码久久一区二区| 中文字幕精品一区影音先锋| 国模极品一区二区三区| 国产激情一区二区三区成人91 | 精品国产一区二区三区久久蜜臀| 中文字幕不卡一区| 精品欧洲av无码一区二区14| 午夜视频一区二区三区| 中文字幕永久一区二区三区在线观看| 无码精品一区二区三区免费视频| 无码国产精品一区二区免费式直播 | 国产成人一区二区动漫精品| 一区二区免费国产在线观看| 日本成人一区二区| 国产一区二区三区乱码在线观看| 国产成人精品无人区一区| 精品无码一区二区三区爱欲九九 | 国产日产久久高清欧美一区| 亚洲乱码国产一区网址| 亚洲第一区视频在线观看| 国产丝袜无码一区二区视频| 无码国产精品一区二区免费3p| 在线播放精品一区二区啪视频| 在线精品国产一区二区三区| 免费一本色道久久一区| 香蕉免费一区二区三区| 精品一区二区三区免费毛片| 亚洲一区二区三区精品视频| 国产高清在线精品一区二区| 无码夜色一区二区三区| 无码国产伦一区二区三区视频|