用Webpack開發Vue Plugin並公開發佈到npm上

2018/05/24

開始之前

會想寫這篇

是因為大部分的經驗都是使用別人的套件

有時候自己想做一些套件出來讓自己或更多人能使用

而我在開發Vue Plugin的過程遇到滿多問題

包含發佈到npm上的時候也是

所以特別開一篇文章來記錄一下

實做Repo: https://github.com/ciao-chung/vue-plugin-example

 

主要流程

大致的流程如下

  1. 用vue-cli建立webpack專案
  2. 設定Github Demo Page, 讓目前dev模式直接可以變成Demo Web
  3. 開始開發套件
  4. 設定套件publish模式(注意這邊是套件的publish, 不是整個Web的production)
  5. 將babel-loader設定獨立抽出
  6. 建立套件publish的相關設定
  7. 發佈到npm

 

用vue-cli建立webpack專案

這邊就只是單純用vue-cli打出基本的webpack樣板

vue-cli的使用方式就不多做說明

vue init webpack vue-plugin-example

 

設定Demo Page

這邊的Demo Page除了開發的時候可以拿來測試功能

最後還能以Github Page形式變成Demo或文件網站

就看需求來決定用途

先簡單做一個Demo Page就好

<template>
  <div>
    <h1>Demo Page</h1>
  </div>
</template>

<script>
export default {}
</script>

<style lang="sass" type="text/sass" scoped></style>

 

修改webpack production設定

如果沒有要用Github Page來當Demo Page或文件網站的話

可以直接略過這一步驟

 

這邊我是希望將production的輸出資料夾名稱改成docs(vue-cli預設打出來是dist)

而docs資料夾可以直接用來當Github Page(參考文件)

因此需要對webpack production設定做一些修改

config/index.js中找到build property

接著修改裡面的indexassetsRoot

從dist改成docs

image.png

 

如果你的Github Page的domain不是自訂而是預設的

那這邊還需要修改assetsPublicPath

這步驟很重要

因為官方預設的Github Page結構大概是這樣https://username.github.io/repo-name

如果沒修改assetsPublicPath的話打包出來的index.html會找不到其他的js跟css檔

整個web會直接空白給你看

所以這一步我們需要將assetsPublicPath改成repo name

這邊結尾記得要補斜線

image (1).png

 

測試Github Page

這邊跟上一步驟一樣

如果沒有要用Github Page來當Demo Page或文件網站的話

可以直接略過這一步驟

 

這時候我們可以先執行打包測試輸入資料夾名稱是不是docs

npm run build

 

確定輸出成功後就沒問題了

這時候可以直接push到Github上

整個docs資料夾給他commit然後push上Github就對了

image (2).png

 

push完成後

接著登入Github進入該repo的設定頁

設定Github Page選項

選擇第二個把master下的docs資料夾當然Github Page的選項並儲存

另外我有發現這個選項是要在master有docs資料夾的情況下才會出現

所以要先push才有得選

image (3).png

 

儲存成功後就會看到Github Page的網址了

直接點進去看

如果是剛才設定的Demo Page那就代表ok了

之後只要想發佈新的Demo Page或文件網站

只要執行npm run build再commit + push就可以了

image (4).png

 

image (5).png

 

開始開發套件

(這部份可參考src下的結構)

因為這邊主要是要介紹Vue Plugin的開發過程

所以plugin就用個最陽春最簡單的dialog來示範就好

Peek 2018-05-27 12-23.gif

 

官方文件可以看到Vue Plugin可以分為四種方式被安裝

並經由Vue.use method使用該套件

這邊我選擇第四種

也就是直接擴充Vue的prototype

讓每個Vue的實體可以直接call這個method

 

這是最主要的Installer.js

到時候發佈package的進入點就是指向這個js

而在Demo Page中也是載入這個js來安裝套件測試

import Dialog from '@/components/Plugin/Dialog.vue'
import Vue from 'vue'
import { events } from '@/components/Plugin/Events.js'

class Installer {
  constructor() {
    this.isInstalled = false
  }
  install(Vue, options) {
    if(this.isInstalled) return
    this.isInstalled = true

    // 載入主要的dialog元件
    Vue.component('VueDialogPlugin', Dialog)

    Vue.prototype.$dialog = (options) => {
      events.$emit('dialog', options)
    }
  }
}

export default new Installer()

 

接著是Installer.js裡面的Events.js

這是為了使用Vue的event bus來做套件的觸發

import Vue from 'vue'
export const events = new Vue()

 

再來是最重要的套件本體Dialog.vue

主要是監聽Installer.js裡面定義的event

被觸發時做切換dialog動作

<template>
  <div vue-dialog v-if="active">
    <h2>{{title}}</h2>
    <div>{{content}}</div>
    <button @click="close">close</button>
  </div>
</template>

<script>
import { events } from '@/components/Plugin/Events.js'
export default {
  data() {
    return {
      title: null,
      content: null,
      active: false,
    }
  },
  async created() {
    events.$on('dialog', this.open)
  },
  methods: {
    open(options) {
      // 從Demo.vue經由installer傳來的options
      this.active = true
      this.title = options.title || 'Title'
      this.content = options.content || null
    },
    close() {
      this.active = false
    },
  },
}
</script>

<style lang="sass" type="text/sass" scoped>
div[vue-dialog]
  position: fixed
  z-index: 5000
  top: 100px
  left: calc(100vw/2 - 400px/2)
  border: 2px gray solid
  width: 400px
  height: 300px
  padding: 15px
</style>

 

最後在App.vue安裝套件

<template>
  <div id="app">
    <router-view/>

    <!--依照Installer.js訂的元件名稱掛載元件-->
    <VueDialogPlugin/>
  </div>
</template>

<script>
// 安裝Plugin,
import Vue from 'vue'
import Dialog from '@/components/Plugin/Installer.js'
Vue.use(Dialog)

export default {
  name: 'App'
}
</script>

<style lang="sass" type="text/sass" scoped></style>

 

並且在Demo.vue裡面加入測試按鈕

<template>
  <div>
    <h1>Demo Page</h1>

    <!--測試按鈕, 點選後開啟dialog-->
    <button @click="open">open</button>
  </div>
</template>

<script>
export default {
  methods: {
    open() {
      this.$dialog({
        title: 'foo',
        content: 'bar',
      })
    },
  },
}
</script>

 

到這邊可以算是全部開發完了

再執行打包一次然後commit push

就可以在Demo Github Page上測試套件

 

新增套件的publish

前面的npm run build是Demo Page的production

而這邊是要處理套件本身的publish

 

image (6).png

從vue-cli的webpack template結構中

我們可以看出有devproduction模式(如果vue-cli裝webpack template時有安裝測試功能, 還會多一個test模式)

現在我們需要再加一個套件的publish模式

 

image (7).png

建立publish模式的話一開始可以先直接複製production設定

以下是大概要建(ㄈㄨˋ)立(ㄓˋ)的幾個設定

  1. build/webpack.prod.conf.js複製一個webpack.publish.conf.js檔案
  2. build/build.js複製一個build/publish.js檔案
  3. config/prod.env.js複製一個config/publish.env.js檔案(裡面參數記得也要改成publish)
  4. 最後config/index.js裡面的build property也要記得複製出一個publish property

 

接著我們在package.json加入發佈套件的npm script

也就是執行剛才建立的build/publish.js

{
  // ...
  "scripts": {
    // ...
    "publish-plugin": "node build/publish.js"
  }
}

 

修改vue-loader.conf.js裡面的isProduction

這邊我們需要在production模式與publish模式中使用extract選項

所以要修改一下isProduction

const isProduction = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'publish'

 

設定index.js的publish property

這邊主要是設定publish模式的一些路徑

 

刪掉index property

首先可以直接將index property刪掉

因為我們只是要打包一個Vue Plugin(進入點是src/components/Plugin/Installer.js)

所以我們不需要打出index.html

 

將輸出資料夾改成publish

我希望publish模式輸出成一個publish資料夾

所以這邊要修改assetsRoot property

assetsRoot: path.resolve(__dirname, '../publish'),

 

將assetsPublicPath改成root

前面的assetsPublicPath設定成/vue-plugin-example/是為了Github Page而設定的

這邊要改成root

assetsPublicPath: '/',

 

差異

image (8).png

 

設定build/webpack.publish.conf.js

這個檔案是publish模式的webpack設定

有些設定是為了覆蓋掉webpack.base.conf.js的設定

 

env改成載入config/publish.env.js

const env = require('../config/publish.env')

 

把config的設定全部改成publish

首先先將全部的config.build設定整批換成config.publish

這個config就是require config/index.js的設定

也就是上個步驟修改的publish相關路徑

所以我們全都要改成publish的設定

 

打包時排除vue避免檔案過大

這邊可以使用webpack的externals選項

打包時將vue排除

externals: [/^vue$/i],

 

設定進入點

接著我們要設定entry propery改成由src/components/Plugin/Installer.js進入

覆蓋掉webpack.base.conf.js中的entry property

也就是publish後

使用者安裝這個套件

將會直接載入Installer.js

entry: './src/components/Plugin/Installer.js',

 

修改output property

把原本的chunkFilename property拿掉

並且直接將filename指定成dist/vue-plugin-example.js

到時候將會輸出在publish/dist資料夾中

接著加上libraryTarget: 'umd'設定

這邊是使用webpack的output libraryTarget選項

主要是將這個library設定成能在CommonJS中使用

也就是打包的時候webpack會去幫我們處理ES6/ES7的code

output: {
  path: config.publish.assetsRoot,
  filename: 'dist/vue-plugin-example.js',
  libraryTarget: 'umd',
},

 

修改ExtractTextPlugin設定

原本的設定太過複雜會打包成很多支css檔

我這邊希望只要打成一支css讓使用套件的人方便載入

new ExtractTextPlugin({
  filename: 'dist/vue-plugin-example.css',
}),

 

拿掉所有CommonsChunkPlugin

直接把所有CommonsChunkPlugin都拿掉

原因跟上一點一樣

我只想打出一支js讓使用者方便載入

 

拿掉HtmlWebpackPlugin的設定

因為這是一個Vue Plugin

程式的進入點是一支js

所以我們要把原本的HtmlWebpackPlugin直接拿掉

不然publish的時候會跟production一樣打出index.html

 

設定build/publish.js

以原本build/build.js來說

它是開始執行production打包的進入點

而我們現在需要修改build/publish.js

改成使用上面修改完的幾個設定來執行打包

 

把config的設定全部改成publish

首先一樣先將全部的config.build設定整批換成config.publish

全都改成publish的設定

 

環境變數改成publish

process.env.NODE_ENV = 'publish'

 

webpackConfig改成載入前面設定的webpack.publish.conf.js

const webpackConfig = require('./webpack.publish.conf')

 

差異

image (9).png

 

直接執行publish script看看輸出結構是否正常

上面的步驟都設定完後

我們就可以直接執行前面新建立的publish script來看看打包出來的結果是否正確

npm run publish-plugin

 

從輸出結果來看

我們可以看到有依照我們的設定

在publish/dist打出一支js跟map檔

image (10).png

 

image (11).png

 

將babel-loader設定獨立抽出

這步驟是為了改善publish打包壓縮

如果這個步驟沒做的話套件仍然可以正常發佈使用

 

雖然前面的步驟已經可以成功打包出套件

不過套件的壓縮還是可以再改善

因為vue-cli webpack template中的babel-loader設定是使用.babelrc

而裡面使用的transform-runtime

會在publish打包時做加入babel polyfill而造成檔案過大

因此如果將babel-loader的設定抽出來動態載入transform-runtime

這樣打包出來的套件size將會很明顯的被壓縮

 

建立babel-loader的獨立設定檔

直接在build資料夾下建立一個babel-loader.conf.js

另外constructor傳入的resolve是因為不想在重複建立build/webpack.base.conf.js的那個resolve method

所以直接從外部注入使用

 

options就直接沿用原本.babelrc的內容

plugin先預設不使用transform-runtime

class BabelLoaderConfig {
  constructor(resolve) {
    const isPublish =  process.env.NODE_ENV === 'publish'

    this.config = {
      test: /\.js$/,
      loader: 'babel-loader',
      include: [
        resolve('src'),
        resolve('test'),
        resolve('node_modules/webpack-dev-server/client')
      ],
      options: {
        presets: [
          ['env', {
            modules: false,
            targets: {
              browsers: ['> 1%', 'last 2 versions', 'not ie <= 8']
            }
          }],
          'stage-2'
        ],
        plugins: ['transform-vue-jsx']
      }
    }

    // 只有不是在套件publish的時候才使用transform-runtime
    if(!isPublish) this.config.options.plugins.push('transform-runtime')
  }

  exportConfig() {
    return this.config
  }
}
module.exports = function (resolve) {
  return new BabelLoaderConfig(resolve).exportConfig()
}

 

修改build/webpack.base.conf.js

直接將原本的babel部份取代成新的babel設定(記得要注入resolve)

image.png

 

刪掉.babelrc

既然已經將babel-loader的設定抽出來了

直接刪掉.babelrc

不然babel-loader還會一直讀取原本的.babelrc設定

 

比較一下壓縮後的大小

圖片上方是原本vue-cli webpack template的.babelrc打出來的結果

下方則是修改完babel-loader設定之後的打包結果

可以看到vue-plugin-example.js的大小直接從29.4k變成3.09k

幾乎只有原本的十分之一

而map檔也是將近變成原來的十分之一

image (1).png

 

在dev模式載入打包好的套件測試

接著我們可以直接把App.vue的components/Plugin/Installer.js換成剛才打出來的publish/dist/vue-plugin-example.js

直接在dev測試剛才打出來的檔案是否能被正常使用

<template>
  <div id="app">
    <router-view/>

    <VueDialogPlugin/>
  </div>
</template>

<script>
import Vue from 'vue'
import Dialog from '@/../publish/dist/vue-plugin-example.js'
Vue.use(Dialog)

export default {
  name: 'App'
}
</script>

<!--css也要記得載入-->
<style src="@/../publish/dist/vue-plugin-example.css"></style>

 

如果換完之後在dev模式可以正常使用該plugin開發的功能

那就代表publish script打出來的檔案沒問題

可以準備發佈套件了

 

加入套件的package設定

在將套件發佈到npm之前

我們要先設定這個package

 

設定package

進到將要發佈的publish資料夾中

執行npm inityarn init

進行該套件的初始化

這邊要注意的是entry point要記得設定為dist/vue-plugin-example.js

image (2).png

 

加入相依性

如果你開發的套件有相依其他的套件

就要在package.json加上相依性

如果是用yarn的話也要更新yarn.lock

不過目前這個範例沒有相依其他套件

所以就可以略過這個步驟

 

設定.npmignore

通常我們需要排除掉一些檔案或資料夾

避免跟著被發佈到npm上

這時候我們就需要從.npmignore上設定

node_modules/*
.idea/*
npm-debug.log
example/*
example/.*
.DS_Store
node_modules/
yarn-debug.log*
yarn-error.log*
/test/unit/coverage/
/test/e2e/reports/
selenium-debug.log

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln

 

設定.gitignore

通常要發佈到npm的publish資料夾中

除了package.json、yarn.lock、.npmignore這三個檔案之外

其他都不需要進git

所以我們要針對publish資料夾做.gitingore的設定

publish/*
!publish/package.json
!publish/yarn.lock
!publish/.npmignore

 

發佈到npm

這發佈之前要先註冊npm帳號

接著進到publish資料夾中

先執行npm login登入npm帳號

接著執行npm publish後

只要看到下列訊息

就代表套件發佈成功

上npm搜尋套件名稱應該也可以正常搜到

image (3).png

 

之後如果要更新版本

只需要更新package.json裡的version

再次重複上面的動作

就能更新套件的版本了

 

其他可能遇到的問題或需求

這邊主要是一些可能會遇到的問題或需求

 

每次publish的時執行一些shell

假設我希望每次在publish打包之前把整個publish/dist資料夾刪除

並且在publish打包成功之後複製README.md到publish中讓npm中永遠有最新的文件

這時候可以在build/webpack.publish.conf.js中使用webpack-shell-plugin

並且加入設定

new WebpackShellPlugin({
  onBuildStart: [
    'rm -rf publish/dist/',
  ],
  onBuildEnd: [
    'cp README.md publish/'
  ],
}),

 

完整設定請參考build/webpack.publish.conf.js

 

npm publish顯示成功卻在其他專案裝不起來

大部分時候都沒這個問題

不過有一次發佈一個套件

在npm的package list中明明有看到發佈的專案成功

但是不管是用npm還是yarn都裝不起來

https://registry.npmjs.org中找也是404(可以使用https://registry.npmjs.org/package-name來查詢package資訊)

結果過了兩三個小時候就可以裝

我猜應該是那個時候太多人同時發佈

npm可能publish後要先進佇列

人多塞車才會等久一點

所以遇到這個問題可以先等個幾小時看看

真的太久沒有的話

再寫信給npm問吧