写在最前面
研究了很久,到最后才发现自己错了,对lerna的理解有问题,但是还好错的不是很彻底。
如果你是想单纯做一个类似于antd的组件库,那么,不要用此套方案,这套方案是类似于babel的,比如你要单独使用babel某个插件需要安装@babel/xxxxx
,这样,但是组件库大部分使用场景不会是用一个组件安装一个组件,肯定是直接安装一个组件库然后引用其中某些模块,所以这个方案完全与想法背道而驰。
正确思路是研究rollup
的tree shaking
rollup
好处:
- 支持导出ES模块的包。
- 它支持程序流分析,能更加正确的判断项目本身的代码是否有副作用。我们只要通过rollup打出两份文件,一份umd版,一份ES模块版,它们的路径分别设为main,module的值。这样就能方便使用者进行tree-shaking。
不过我们组还是采用了这种方式,直接将组件库的源代码和各个项目代码用lerna+yarn workspace统一管理。至于怎么打包组件库,在文章最后面~
一、基础介绍
1、yarn workspace
官网介绍
简单翻译一下:
yarn workspace
允许将项目分包管理,只用在根目录执行一次yarn install
即可安装子包中的依赖。并且允许一个子包依赖另外一个子包。
优点:
- 工作区中的项目可以相互(单向)依赖,保持使用最新的代码,只会影响
workspace
中的内容,不会影响整个项目,比yarn link
的机制更好。 - 所有子包中的依赖都安装在一起,可以更好的优化空间。
- 每个项目独立一个
yarn.lock
,这意味着更少的冲突,代码检查也更简单。
缺点/局限性:
- 开发和打包后的结构会有所不同,
workspace
中的依赖会被提升到更高的位置,如果这里出现问题了,可以用nohoist尝试修复 - 由于有的包要依赖老包打包新的包,所以如果子包中的依赖项版本与
workspace
中的版本不一样,这个依赖会从npm安装打包而不是从本地的文件系统中打包。 - 发包时如果用到了新版本的依赖,但是没有在
package.json
中声明的话,使用发布版本的用户会出现问题,因为他们不会更新依赖项。这个问题目前没有办法警告。 - 目前不支持嵌套工作区,也不支持跨工作区引用。
以上,翻译如有问题,评论区提出
2、lerna
开源js分包管理工具,子包依赖管理,分包发布。
3、rollup & rollup vs webpack
模块打包,库打包用rollup
application打包用webpack
4、storybook
Storybook 是一个用于 UI 开发的工具。 它通过隔离组件使开发更快、更容易。 这允许您一次处理一个组件。 您可以开发整个 UI,而无需启动复杂的开发堆栈、将某些数据强加到您的数据库中或浏览您的应用程序。(google机翻)
总之storybook提供了一个可视化开发组件的解决方案,可以很方便的调试样式、参数,还能开发的时候顺手写了文档,还挺方便。
二、项目搭建流程
全局安装lerna,yarn,rollup
npm i lerna yarn rollup -g
新建文件夹,作为项目名,以下用demo
作为项目名
// 进入文件夹
cd demo
// 初始化npm,设置对应字段
npm init
// 初始化lerna仓库
lerna init
// 打开yarn workspaces
yarn config set workspaces-experimental true
在packages.json中添加:
"private": true,
"workspaces": ["packages/*"],
安装打包工具rollup,由于是添加到workspaces根的package.json中,所以需要加-W配置
yarn add rollup -D -W
我们是用ts开发的,所以要安装ts和ts-node
yarn add typescript ts-node -D -W
ts配置:
tsc --init
// tsconfig.json
{
"extends": "./tsconfig.extend.json",
"compilerOptions": {
"module": "commonjs"
}
}
// tsconfig.rollup.json
{
"extends": "./tsconfig.extend.json",
"compilerOptions": {
"module": "esnext",
"declaration": true
}
}
// tsconfig.extend.json
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"downlevelIteration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react",
"resolveJsonModule": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"noUnusedLocals": false,
"sourceMap": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es2015.collection",
"es2015.iterable",
"es2015.promise",
"es5"
]
},
"include": ["**/*.ts", "**/*.tsx", "*.ts"],
"exclude": [
"node_modules",
"dist"
]
}
根目录新建rollup.build.ts
,编写打包流程,需要安装引入的模块,其中,yargs-parser要安装对应的ts types @types/yargs-parser
,fs-extra
需要安装@types/fs-extra
import chalk from 'chalk' // 控制台输出彩色文案
import execa from 'execa' // 开启子进程执行命令,https://www.npmjs.com/package/execa
import fse from 'fs-extra' // file system的扩展方法,此处用它同步读取json
import globby from 'globby' // 增强版本glob,此处用它同步匹配文件名
import path from 'path' //路径工具
import { InputOptions, OutputOptions, rollup } from 'rollup'
import commonjs from 'rollup-plugin-commonjs'
import scss from 'rollup-plugin-scss'
import nodeResolve from 'rollup-plugin-node-resolve'
import typescript from 'rollup-plugin-typescript2'
import ts from 'typescript'
import yargs from 'yargs-parser'
import lernaJson from './lerna.json'
interface IOpt extends InputOptions {
output: OutputOptions[]
}
// 命令要做什么,all则编译所有包,changed则编译发生改变的包,默认为all
const argv = yargs(process.argv)
const type: 'all' | 'changed' | undefined = argv.type
export class Run {
/**
* 流程函数
* @param ohterPkgPaths 其他包,可用来排除
* @param external 排除不打包到dish里面的包
*/
public async build(ohterPkgPaths: string[] = [], external: string[] = []) {
const pkgPaths: string[] = this.getPkgPaths(lernaJson.packages)
// rollup配置列表
const rollupConfigList = [...pkgPaths, ...ohterPkgPaths].map(
(pPath) => {
const pkg = fse.readJsonSync(pPath)
const libRoot = path.join(pPath, '..')
const isTsx = fse.existsSync(path.join(libRoot, 'src/index.tsx'))
return {
input: path.join(libRoot, isTsx ? 'src/index.tsx' : 'src/index.ts'),
plugins: [
scss(), // 我们这里用scoped scss来写样式,所以打包使用scss预处理样式
nodeResolve({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
}),
typescript({
check: false,
tsconfigOverride: {
compilerOptions: {
baseUrl: libRoot,
outDir: path.join(libRoot, 'dist'),
allowSyntheticDefaultImports: true,
},
include: [path.join(libRoot, 'src')],
},
typescript: ts,
tsconfig: path.join(__dirname, 'tsconfig.json'),
}),
commonjs({
include: path.join(__dirname, 'node_modules/**'),
}),
],
external: [
...Object.keys(pkg.dependencies || {}),
...(pkg.external || []),
...external,
],
output: [
{
file: path.join(libRoot, pkg.main),
format: 'cjs',
exports: 'named',
globals: {
react: 'React',
},
},
{
file: path.join(libRoot, pkg.module),
format: 'esm',
exports: 'named',
globals: {
react: 'React',
},
},
],
} as IOpt
}
)
for (const opt of rollupConfigList) {
console.log(chalk.hex('#009dff')('building: ') + opt.input)
// 打包
const bundle = await rollup({
input: opt.input,
plugins: opt.plugins,
external: opt.external,
})
// 输出
for (const out of opt.output) {
// await bundle.generate(outOpt)
await bundle.write(out)
console.log(chalk.hex('#3fda00')('output: ') + out.file)
}
}
}
/**
* 打印找到发生改变的包的日志
* @param changes 发生改变的pkg
*/
private logFindChanged(
changes: Array<{ name: string; location: string; version: string }>
) {
const logInfo = chalk
.hex('#009dff')
.bold('find changed: ' + (changes.length === 0 ? 'nothing changed' : ''))
console.log(logInfo)
changes.map((item) => {
console.log(item.name)
})
}
/**
* 获得需要编译的包的package
* @param lernaPkg lerna.json中的packages
*/
private getPkgPaths(lernaPkg: string[]) {
const lernaPkgPaths = lernaPkg.map((p) =>
path.join(__dirname, p, 'package.json').replace(/\\/g, '/')
)
if (type === 'changed') {
const changes = this.getChangedPkgPaths()
// 如果发生改变,输出日志
this.logFindChanged(changes)
return changes.map((p) => path.join(p.location, 'package.json'))
}
return globby.sync(lernaPkgPaths)
}
/**
* 获得发生改变的包
*/
private getChangedPkgPaths(): Array<{
name: string
location: string
version: string
}> {
const { stdout } = execa.sync('lerna changed --json')
const matchPkgStr = stdout.replace(/[\r\n]/g, '').match(/{.+?}/g)
return (matchPkgStr || []).map((item) => {
return JSON.parse(item)
})
}
}
const run = new Run()
run.build()
package.json
中添加打包和发布命令, 如果需要发布到私人npm仓库,则需要在release中添加 --registry (私人npm仓库地址),如发布至npmjs.org
则不需要
"scripts": {
"build:changed": "ts-node rollup.build.ts --type changed",
"build:all": "ts-node rollup.build.ts --type all",
"release": "lerna publish --no-push --registry (私人npm地址)",
"updated": "lerna updated"
},
可选:使用husky
做本地的git hooks,结合lint-staged
做代码lint:
yarn add husky lint-staged -D -W
然后在package.json
中添加配置:
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"packages/**/*.scss": "stylelint --fix"
}
三、开发,打包,发布
1、开发准备
其实做完上面的内容,就可以开发js工具库发布了,但是我们这次的目的是做组件库,组件库少不了视觉调试和文档,我采用的是storybook
,其他这种类似框架还有umi
的dumi
,可以自行尝试。
storybook
无需安装,直接使用npx
// 项目根目录
npx sb init
由于storybook
是根据你项目目前的开发环境语言等来检测如何初始化的,但是我们目前还没有进入开发,所以,询问是否手动选择项目环境时,输入y
,选择react_project
即可。
等待初始化完毕...
项目根目录会生成.storybook
和stories
两个文件夹,前者是配置文件的文件夹,后者是模板文件夹。
修改.storybook/main.js
,删除stories
文件夹。
// .storybook/main.js
module.exports = {
"stories": [
"../packages/**/example/*.stories.@(js|jsx|ts|tsx)",
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials"
]
}
2、开发
我们以一个Button组件进行演示
// 全局安装react(因为其他包也会用到)
yarn add react @types/react react-dom @types/react-dom node-sass -D -W
lerna create @demo/Button
这里我们使用scoped scss做为style格式,storybook
的webpack可能用的不是webpack5
,所以我们要使用老版本的node-sass
和sass-loader
,并且需要配置sass-loader
。
yarn add [email protected] [email protected] -D -W
// .storybook/main.js
const path = require('path');
module.exports = {
"stories": [
"../packages/**/example/*.stories.@(js|jsx|ts|tsx)",
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
],
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
// 'PRODUCTION' is used when building the static version of storybook.
// Make whatever fine-grained changes you need
config.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
include: path.resolve(__dirname, '../'),
});
// Return the altered config
return config;
},
}
在/packages/Button
中进行组件开发,由于我们是组件,删除/lib
,新建/src
和/example
,代码如下,这里我们直接用storybook
的官方示例了
// /src/index.tsx
import React from 'react';
import './index.scoped.scss';
interface ButtonProps {
/**
* Is this the principal call to action on the page?
*/
primary?: boolean;
/**
* What background color to use
*/
backgroundColor?: string;
/**
* How large should the button be?
*/
size?: 'small' | 'medium' | 'large';
/**
* Button contents
*/
label: string;
/**
* Optional click handler
*/
onClick?: () => void;
}
/**
* Primary UI component for user interaction
*/
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
);
};
// /src/index.scoped.scss
.storybook-button {
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}
// /example/index.stories.tsx 这个就是.storybook/main.js中配置的模板演示文件的位置
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Button } from '../src/index';
export default {
title: 'Example/Button',
component: Button,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta;
const Template: ComponentStory = (args) => ;
export const Primary = Template.bind({});
Primary.args = {
primary: true,
label: 'Button',
};
export const Secondary = Template.bind({});
Secondary.args = {
label: 'Button',
};
export const Large = Template.bind({});
Large.args = {
size: 'large',
label: 'Button',
};
export const Small = Template.bind({});
Small.args = {
size: 'small',
label: 'Button',
};
这时控制台输入yarn storybook
,就能启动看到storybook的网页了,如下。
3、打包
需要修改包的package.json
文件
"main": "dist/bundle.cjs.js",
"module": "dist/bundle.ems.js",
"types": "dist/index.d.ts",
注意,子包的依赖也要在package.json中引入,进入子包目录,用yarn add
的方式添加依赖。如果是根存在的公共依赖,不会安装,如果是独特的依赖,会单独引入(由于使用了yarn workspaces,会在根目录的node_modules统一安装)
依赖add完毕之后,在项目根目录运行yarn build:all
或者yarn build:changed
就会发现子包根目录出现dist文件夹,这里面就是打包出来的文件。
4、发布
发布之前需要先上传git,无论是公司git还是github或者第三方其他git,只要有版本管理即可。
运行yarn release
选择特性即可。
四、其他
eslint代码格式规范
init最后询问是否执行npm i,选否,因为我们是用yarn来管理的,自己手动执行yarn install
就行了
npm i eslint -g
eslint --init
配置参考:(直接拿了之前脚手架的来用)
module.exports = {
env: {
browser: true,
es2021: true,
},
globals: {
JSX: true,
},
extends: [
'airbnb-typescript',
'airbnb/hooks',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
tsx: true,
modules: true,
},
ecmaVersion: 2020,
sourceType: 'module',
project: './tsconfig.json',
},
plugins: ['react', '@typescript-eslint'],
settings: {
'import/resolver': {
alias: {
map: [['@', './src/']],
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
project: './tsconfig.json',
},
},
},
rules: {
'react/jsx-filename-extension': [
2,
{
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
],
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
ts: 'never',
jsx: 'never',
tsx: 'never',
},
],
'max-len': [0],
'react/jsx-one-expression-per-line': 0,
'react/state-in-constructor': 0,
'react/self-closing-comp': 0,
'react/prefer-stateless-function': 0,
'react/static-property-placement': 0,
'max-classes-per-file': 0,
'react/sort-comp': 0,
'jsx-a11y/no-noninteractive-element-interactions': 0,
'jsx-a11y/click-events-have-key-events': 0,
'jsx-a11y/control-has-associated-label': 0,
'jsx-a11y/anchor-has-content': 0,
'react/no-unused-state': 0,
'jsx-a11y/anchor-is-valid': 0,
'no-plusplus': 0,
'jsx-a11y/no-static-element-interactions': 0,
'jsx-a11y/alt-text': 0,
'class-methods-use-this': 0,
'import/prefer-default-export': 0,
'no-console': 0,
'react/jsx-props-no-spreading': 0,
'no-param-reassign': 0,
'no-shadow': 0,
'jsx-a11y/media-has-caption': 0,
'import/no-unresolved': [2, { ignore: ['react', 'react-dom'] }],
semi: ['error', 'never'],
'prettier/prettier': ['error', { semi: false, singleQuote: true }],
'@typescript-eslint/no-empty-function': 'off',
},
};
使用以上配置,需要安装一些插件:
yarn add eslint-config-airbnb-typescript eslint-config-prettier prettier eslint-plugin-prettier eslint-import-resolver-alias eslint-import-resolver-typescript babel-plugin-import -D -W
五、最终解决方案
直接lerna create @demo/basic-component
,创建组件库子包,创建src
文件夹,在src
下创建各组件的文件夹,如Button
,将上面的Button
子包中的src
移至Button
文件夹,src
下新建index.tsx
,内容如下
import { Button } from './Button'
export { Button }
修改package.json
同Button
子包的package.json
,添加依赖,添加main``module``types
。上传,build,release,发布成功。
注意storybook的模板也要移过去
测试:
新建项目,npm i @demo/basic-component
,引入组件。
成功!