Lerna+webpack+juction来拆分组件库为多个单独的npm包

1,925 阅读12分钟

前不久发布了vc-popup组件集, 但是那时候完全只是展示没有如何使用的教程, 因为当时急于发布出来, 实在不妥, 抱歉~

既然是想自己东西可以让别人方便使用, 那就是打包成npm的包咯, 但是考虑vc-popup仅仅是popup的组件集, 不是完整的组件库, 所以很多时候用户仅仅想使用某个popup, 那么其他popup也打包进去, 就浪费带宽了, 所以需要一个每个popup单独发布到npm上去, 但是把依赖分开的时候之后开发就是带来不便, 比如一个包更新了, 需要在另一个手动更新, 为了解决这个不便, 就是Lerna登场的时候了, 用来方便开发和管理多个package~

但是自己实践的过程当中遇到一些问题和还有踩过一些坑, 所以在这里记录, 不过在开始之前, 先提一下vc-popup的更新

12-08: imgView支持懒加载图片,从加载状态的预设图片到加载完成的src同步变化~

popup-img-viewer2.gif


安装Lerna

目前知道3种办法, 如果在使用vscode同学, 使用cnpm时候附带--by=npm 可以避免rg.exe吃CPU的问题, 同理可以设置为--by=yarn, 一些包使用cnpm安装有问题的时候, 就可以使用让cnpm仅仅做下载, 安装交给npm/yarn

> npm i -g lerna
> cnpm i -g lerna --by=npm
> yarn global add lerna

初始化一个demo

在日常使用输入命令的时候常用**&&加快效率, 自己输入的次数多了, 才发现命令行相比于界面的优点在于可以串联多个简单的任务, 这个学期开始学习操作系统, 发现有个类似的名词单道批处理系统CMD批处理脚本**, 所以不言而喻咯~ 摁{enter}键的时候想想还有什么命令可以提前敲进去的

还有一个优点是, 命令是基于字符组合的确定, 而非界面位置, 所以界面需要层叠, 命名不需要, 字符组合容量大

> mkdir lerna-demo && cd lerna-demo && lerna init

前面因为需要穿插cnpm所以安装部分没有串联

由于键盘右边shift键位问题, 其实输入&&的时候并不是那么顺畅, 可以通过AHK来做转接, 我一般用笔记本键盘的时候按aand{space}生成&&{space}, 自己做的键盘, 因为调整过shift的位置就还是按&&

生成的查看生成的文件和目录

> ls
lerna.json  package.json  packages

分别查看文件内容

> head lerna.json && head package.json
{
  "lerna": "2.5.1",
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}
{
        "devDependencies": {
                "lerna": "^2.5.1"
        }
}

然后新建目录s

> cd packages && mkdir module-0 module-1 module-2

初始化package.json

> cd module-0 && npm init -y && cd ../module-1 && npm init -y && cd ../module-2 && npm init -y
Wrote to D:\DEV\Github\demo\lerna-demo\packages\module-0\package.json:

{
  "name": "module-0",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": ""
}


Wrote to D:\DEV\Github\demo\lerna-demo\packages\module-1\package.json:

{
  "name": "module-1",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}


Wrote to D:\DEV\Github\demo\lerna-demo\packages\module-2\package.json:

{
  "name": "module-2",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

初始化每个module的index.js

> echo export default require('./package.json').name > index.js && cat index.js > ../module-0/index.js && cat index.js > ../module-1/index.js

然后在lerna-demo新建index.js并编辑, 因为lerna会维护的是packages/*之间的依赖, 这里的index.js直接填写module-2的路径

> cd ../.. && code index.js
const msg = require('./packages/module-2')

console.log(msg);

设置module之间依赖, 现在require的时候就可以直接填写对应的module

修改module-1的index.js

export default 
  require('./package.json').name 
  + 'depends on [' + require('module-0').default + ']'  

修改module-2的index.js

export default 
  require('./package.json').name 
  + 'depends on [' + require('module-1').default + ']'

思考

正常途径如何添加npm包的依赖? yarn add modue-name

有什么结果? 会从npm仓库下载该包下来, 解压到node_modules/module-name, 然后处理packsage.json依赖

那么是否意味着Lerna也会有这个类似的操作? 如果现在在开发module-2, 但是发现是module-1的bug, 把module-1的bug修改了, 需要发布一下到npm, 然后module-2再更新module-1的依赖, 那么可以猜测Leran通过某种手段让这个更新同步自动化了

那么基于猜测可以进行验证咯~ 先看手册, 查查这个类似的操作是什么~

2017-11-14_112623.png

看Example就很清晰知道的了, 那么开始生成依赖

> lerna add module-0 --scope=module-1
> lerna add module-1 --scope=module-2

那么可以预计操作结果是, module-2的node_modulesmodule-1的文件夹,并且包含了其内容, module-1同理

2017-12-16_092625.png

那么就可以猜测如何实现了

是递归复制文件? 验证一下 那么现在修改一下module-0/index.js 然后,查看module-1/node_modules/module-0/index.js, module-2同理

module-0/index.js该为如下

export default 
  require('./package.json').name + ' edited'

2017-12-16_093248.png

OK, 自动修改是同步更新的, 所以不是, 记得自己看linux的教程的时候有个工具是相关的, ln, 但是我使用的是, 文件系统是NTFS

> ver
Microsoft Windows [Version 10.0.15063]
> ln --help                                                                   
用法:ln [选项]... [-T] 目标 链接名     (第一种格式)                                         
 或:ln [选项]... 目标         (第二种格式)                                              
 或:ln [选项]... 目标... 目录 (第三种格式)                                                
 或:ln [选项]... -t 目录 目标...      (第四种格式)                                        
In the 1st form, create a link to TARGET with the name LINK_NAME.             
In the 2nd form, create a link to TARGET in the current directory.            
In the 3rd and 4th forms, create links to each TARGET in DIRECTORY.           
Create hard links by default, symbolic links with --symbolic.                 
By default, each destination (name of new link) should not already exist.     
When creating hard links, each TARGET must exist.  Symbolic links             
can hold arbitrary text; if later resolved, a relative link is                
interpreted in relation to its parent directory.                 
--more             

但是我用的是windows哦, 那么猜测是通过windows的工具来实现的, 这个时候, 突然我想到了多次重装系统在网上习得的技巧

> mklink --help
The syntax of the command is incorrect.
Creates a symbolic link.

MKLINK [[/D] | [/H] | [/J]] Link Target

        /D      Creates a directory symbolic link.  Default is a file
                symbolic link.
        /H      Creates a hard link instead of a symbolic link.
        /J      Creates a Directory Junction.
        Link    Specifies the new symbolic link name.
        Target  Specifies the path (relative or absolute) that the new link
                refers to.

之前重装系统多了, 会通过mklink把C盘的Users Juction 到D盘去, 之后每次恢复系统的时候一些程序的配置也就不用重新设置的了, 具体可以参考网上的教程, 需要装系统的时候操作的(文件解压出来, 但是还没重启, 启动安装的时候), 记得好像不能在系统安装之后操作

来验证咯, 这时候就不能使用ls -all来查看了(安装了cygwin, 并且把bin目录放在path里了, 所以可以用), 而是需要使用dir

2017-12-16_095526.png

所以, lerna在windows下是通过建立Juction来解决依赖包同步更新的问题~ linux的话, 也就不言而喻咯, 使用的应该是类似的工具ln~

通过webpack设置babel转码, 然后通过lerna-demo/index.out.js来验证结果咯~

> webpack && node index.out.js
Hash: 3378d33b254656002585
Version: webpack 3.10.0
Time: 1031ms
       Asset     Size  Chunks             Chunk Names
index.out.js  4.14 kB       0  [emitted]  main
   [0] ./index.js 83 bytes {0} [built]
   [1] ./packages/module-2/index.js 183 bytes {0} [built]
   [2] ./packages/module-2/package.json 233 bytes {0} [built]
   [3] ./packages/module-1/index.js 183 bytes {0} [built]
   [4] ./packages/module-1/package.json 233 bytes {0} [built]
   [5] ./packages/module-0/index.js 141 bytes {0} [built]
   [6] ./packages/module-0/package.json 196 bytes {0} [built]
module-2 depends on [module-1 depends on [module-0 edited]]

结果就出来了, demo测试通过 再想一下改造vc-popup的时候会可能出现什么问题? Lerna解决的是在**packages/***的依赖, 也就是回到了例子的问题了

const msg = require('./packages/module-2')

console.log(msg);

这里说明的是在不在packages文件夹内就不能享受依赖更新同步的福利了

开工

任何对试验性的改造, 都推荐新建分支里面进行~

> git checkout -b split-packages

总体的思路, 大致上和lerna-demo差不多, 区别在于会根据现有的目录结构做相应的定制, 所以接下来会简单讲思路, 和遇到的问题.

目录结构
> tree src                                
Folder PATH listing for volume Data       
Volume serial number is 0007-86B5         
D:\DEV\GITHUB\OPENSOURCE\VC-POPUP\SRC     
├───components                            
│   ├───gesture-tile-press                
│   ├───picker-view                       
│   ├───popup-base                        
│   ├───popup-bottom-menu                 
│   ├───popup-by-animation                
│   ├───popup-calendar                    
│   ├───popup-center-menu                 
│   ├───popup-datetime-picker             
│   ├───popup-dialog                      
│   ├───popup-dom-relative                
│   ├───popup-img-viewer                  
│   ├───popup-over                        
│   ├───popup-picker                      
│   ├───popup-press-menu                  
│   ├───pull-down-refresh                 
│   ├───swipe-item                        
│   └───swipeplus                         
├───mixins                                
│   └───event                             
└───utils                                 
分析

需要拆成包的是src/components/popup-* 生成的包是vc-popup-*, 入口是index.js 每个包的安装方式都是如下

import Vue from 'vue'
import popup from 'vc-popup-*'

Vue.use(popup)

拆包之后popup-*包和包之间都是属于外部依赖

Vue.use的时候的install函数会先安装依赖的popup

概要
  1. 通过js初始化popup-*目录和package.json
  2. 通过js生成每个popupentry[install.js]
  3. 配置webpack.pkg.conf.js, 配置多入口
  4. lerna设置包之间的依赖, 其他的包都需要依赖popup-base
  5. 实验性的popup通过在package.json设置private: true不发布出去

一共需要新建3个文件, 两个是批处理属性的, 一个就是webpack的配置, 要点在于多入口的配置, 比较简单

需要注意的点

vue的依赖怎么注入?

在webpack打包的时候设置为外部依赖? 然后popup内部直接使用import Vue from 'vue' ?

还是应该依赖于执行Vue.use()时候的Vue?

区别在于是否使用webpack来做项目构建(或者其他打包工具, 不清楚webpack打包出来的模块里面声明的外部依赖, 再通过其他工具打包是否可以兼容)

如果是通过Vue.use()来注入vue的依赖, 那么就可以兼容那些不使用webpack做构建的项目, 通用性更好一些

我是无语线.........................................................................

但是, 如果注意到import popup from 'vc-popup-*', 哈哈哈, vue的导入不需要走webpack, 但是vc-popup-*需要, 所以popup也是需要提供一个script+src的版本才行, 所以还是拥抱es6的模块吧[尬笑]

发布到npm之前的包如何测试

一开始头几次测试都是发布到npm之后再更新再测试的, 其实,并不需要, 在构建完成之后把更新之后的文件同步过去测试项目的node_modules文件夹就好了, 效率提高不少, 这里通过mklinkjunction的方式同步就好了

不过使用自定义使用juction的时候最好记录到一下文档, 把juction的设置写到初始化的脚本里面, 最好编写平台兼容的, ntfs使用mklink, linux系的就使用ln

difference bewteen symbolic link , junction  and  hard link.png

如果使用文件复制来实现同步的方式也是可行, 不过注意, 不要删除node_modules/vc-popup-base文件夹, 再复制该文件夹, 因为开dev server的时候会因为无法找到文件夹而中断, 需要重开那种, 所以直接覆写文件即可

嗯, 测试完再publish而不是publish之后再测试!


具体步骤

生成popup-*目录, 和package.json
var fs = require('fs')
var path = require('path')
var readlineSync = require('readline-sync');
var deleteFolderRecursive = require('./utils').deleteFolderRecursive;
require('shelljs/global');

// 工具函数
function _path(str){
  return path.resolve(__dirname, str)
}

function _package(name){
  return `{
  "name": "vc-${name}",
  "version": "0.0.0",
  "description": "vc-${name}",
  "main": "index.js",
  "scripts": {
    "test": "echo hasn't write test~"
  },
  "author": "deepkolos",
  "license": "MIT",
  "dependencies": {}
}`;
}

function initpkg(dirname){
  var path = _path('../packages/'+dirname);
  if( !fs.existsSync(path) ){
    fs.mkdirSync(path);
    fs.writeFileSync(path+'/package.json', _package(dirname));
  }
}

// 开始
var deleteAllDir = readlineSync.question('是否清空packages下所有目录? (y/n)');

var componentsDir = fs.readdirSync(
  _path('../src/components'), {
    encoding: "utf8"
  });

deleteAllDir.toLowerCase() == 'y' && 
componentsDir.map((dirname) => {
  deleteFolderRecursive(_path('../packages/'+dirname))
})

componentsDir.map(dirname => {
  if(dirname.indexOf('popup-') === 0)
    initpkg(dirname)
});
生成popup-*目录, entery[install.js]
var fs = require('fs')
var render = require('json-templater/string')
var uppercamelcase = require('uppercamelcase')
var path = require('path')
var utils = require('./utils')

var p = function (str){
  return path.resolve(__dirname, str);
}
var PACKAGE_PATH = p('../packages')
var DEPENDANCE_TEMPLATE = `  Vue.use(require('{{name}}'))`
var MAIN_TEMPLATE = `
const version = '{{version}}'
const install = function (Vue, config = {}) {
  if (install.installed) return
{{includeDepend}}
  require('{{self}}')
}

// auto install
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default {
  install,
  version
}
`
var BASE_MAIN_TEMPLATE = `
import { popupRegister, importVue } from '{{self}}'

const version = '{{version}}'
const install = function (Vue, config = {}) {
  if (install.installed) return
{{includeDepend}}
  importVue(Vue)
  require('{{self}}').default.init(Vue)
}

// auto install
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default {
  install,
  version,
  popupRegister
}
`

function build_install(popupName){
  var pkg = require(`${PACKAGE_PATH}/${popupName}/package.json`)
  var version = pkg.version
  var dependanceList = []
  var tpl = popupName === 'popup-base'? BASE_MAIN_TEMPLATE: MAIN_TEMPLATE

  pkg.dependencies &&
  Object.keys(pkg.dependencies).forEach(function(depName){
    dependanceList.push(render(DEPENDANCE_TEMPLATE, {
      name: depName
    }))
  });

  var template = render(tpl, {
    includeDepend: dependanceList.join('\n'),
    version: version,
    self: `../../src/components/${popupName}`
  })
  
  fs.writeFileSync(p(`../packages/${popupName}/install.js`), template);
}

// 开始
utils.mapPkgList(function(popupName){
  build_install(popupName)
})

配置webpack的多入口

const webpackConfig = merge(baseWebpackConfig, {
  module: {
    rules: utils.styleLoaders({
      sourceMap: config.build.productionSourceMap,
      extract: true
    })
  },
  devtool: config.build.productionSourceMap ? '#source-map' : false,
  externals: ['vue', 'vc-popup-base'], //设置外部依赖, 目前比较简单
  plugins: [
    // Compress extracted CSS. We are using this plugin so that possible
    // duplicated CSS from different components can be deduped.
    new OptimizeCSSPlugin({
      cssProcessorOptions: {
        safe: true
      }
    })
  ]
})

fs.readdirSync(path.resolve(__dirname, '../packages'));

webpackConfig.entry = {}
webpackConfig.output = {
  path: path.resolve(__dirname, '../packages/'),
  filename: `[name]/index.js`,
  libraryExport: "default",
  libraryTarget: "umd"
}

utils.mapPkgList(function(popupName){
  webpackConfig.entry[popupName] = 
    path.resolve(__dirname, `../packages/${popupName}/install.js`)
})

module.exports = webpackConfig

剩下的步骤和lerna-demo的一样~

发布

> lerna publish

done~

主流vue组件库的拆包情况

我看了mint-ui, vant, we-vue, weex-ui, cube-ui, fish-ui的大概构建思路

其中只有mint-uiweex-ui从设计开始使用了lerna来拆包, vantpackages但是里面的子目录不包含package.json可能还没引用lerna吧

weex-ui虽然是使用了lerna来拆包, 但是package.json直接使用源码作为入口

2017-12-17_165937.png

感觉mint-ui可以说是最标准的组件库了, 在构建层面来说, 拆出来的包同时是包含源码的, package.json的出口是经过编译的

2017-12-17_165718.png

而我的vc-popup结构是一个混合体, 一开始没有考虑做拆包, 后面加上的, 所以...拆出来的包仅仅包含经过编译的文件...也没有做js, css的分离...

2017-12-17_170538.png

至于子组件的包是否有需要再走一遍编译, cube-ui滴滴团队有后编译的优化建议, 个人感觉也合理, 组件在具体的vue项目是会再有一层编译的, 所以组件发布的时候仅仅发布源码即可, 不过我还是觉得mint-ui是最标准的方式~~

最后, 寻求文章的建议

写到后面似乎有点不够扣题了[faceplam], 不过也因为, 其实思路理清楚之后, 接下来的事情就是编码和调试了

主要想问一下, 像一开始那里穿插的各种小技巧, 和对事物的点滴理解, 不知道大家对这种方式的有什么评价? 其实自己平时也有一些小理解, 但是不足以成文, 所以就打算后面把这些小知识插到相关的具体实例当中去, 如果大家感觉前面部分还不错的话就点赞, 我打算后面都使用这种小知识分享的风格~

希望大家给我的文章提提建议~ 主要是分享的思路上面, 或者对实践的总结上面有什么好的方法或者思路, 指导指导~


vc-popup使用的文档还没完善, 这里给自己写下篇文章的借口~