1 目标
通过 PNPM 创建一个 monorepo(多个项目在一个代码仓库)项目,形成一个通用的仓库模板。
这里以在该 monorepo 项目中搭建 Web Components 类型的组件库为例,介绍从仓库搭建、组件测试到组件发布的整个流程。
这个仓库既可以用于公司存放和管理所有的项目(不限于 Web Components),也可以用于将个人班余的所有积累整合其中。
如不想一步一步搭建,可以直接下载 项目模板。
2 环境要求
核心是 PNPM 和 Node.js,没有特殊的版本要求,只要他俩能对应上即可。
当前项目使用的 PNPM 版本为 9.4.0,Node.js 为 18.20.3。
除了以上两个,项目中也使用到了以下工具或插件,可以按需添加,如不使用则不用考虑其环境要求。
vite(v5.2.0):主要用于项目运行和构建,要求 Node.js v18+ 或者 v20+。
Storybook(v8.1.7):用于组件的测试和展示,要求 Vite v4.0 +。
Changeset(v2.27.5):用于管理版本生成变更日志,无特殊要求。
3 仓库搭建
3.1 新建项目
新建一个文件夹作为项目容器。
这里起名叫 ease-life,意为轻松生活。所有的学习、工作都是为了更好地、更轻松的生活。
3.2 创建目录
3.2.1 apps
在项目根目录下创建 apps 文件夹。
在 apps 下创建 storybook 文件夹。用于测试和展示自定义的 web components。
apps 文件夹主要用来存放应用程序,如:Storybook、VitePress,还可以加上 vue-test、react-test 来对 web components 做测试。
3.2.2 packages
在项目根目录下创建 apps 文件夹。
在 packages 下分别创建 config(配置信息)、web-components(实现组件与框架无关) 文件夹。
- 在 config 文件下创建 eslint、stylelint 以及 typescript,用于存放对应通用的配置
- 在 web-components 创建 text 文件夹,实现一个简单的文本组件。 text 文件夹下创建 src 文件夹。
packages 底下主要包含插件、组件、命令行、类库等,除了以上的内容还可以按需加上 vue-components、react-components、cli、map-library 等等。
形成的目录结构如下:
ease-life
|-- apps
| |-- storybook
|-- packages
|-- config
| |-- eslint
| |-- stylelint
| |-- typescript
|-- web-components
|-- text
|-- src
3.3 添加文件/工具
3.3.1 PNPM 相关
- 在项目根目录下添加文件:pnpm-workspace.yaml,定义 PNPM 的工作空间:
packages:# 匹配 packages 目录下(任意文件夹下)的所有模块-'packages/**'# 匹配 apps 直接子文件夹下的所有模块-'apps/*'
这里的模块,说的是:包含 package.json,可以被发布到 NPM 远程仓库的项目。
- 在项目根目录下添加文件:.npmrc,定义 PNPM 的配置项:
# 允许链接工作空间中的包
link-workspace-packages = true
# 在引用工作空间中的包时,设置前缀为 *,即:使用最新版本的包
save-prefix = ''
3.3.2 Vite 相关
- 在根目录下运行以下内容:
pnpm init
从而生成 package.json,如下:
{"name":"ease-life","version":"1.0.0","description":"","main":"index.js","scripts":{"test":"echo \"Error: no test specified1\" && exit 1"},"keywords":[],"author":"","license":"ISC",}
- 在 web-components、web-components/text、config/eslint、config/stylelint 以及 config/typescript 下都执行 pnpm init,或直接将根目录下的 package.json 拷贝过去。
本文的目的是要每个组件都能够被单独被发布至 NPM 仓库,如:@ease-life/text。如只需要做整个组件库的统一发布,则无需在 web-components/text 下执行 pnpm init。
- 在项目最外层空间下添加 vite:
pnpmadd vite -Dw
packages 里的所有模块如无特殊情况,可统一使用 vite 来运行、打包,因此只需要在项目最外层安装一次即可。
- 在项目根目录下,添加文件 vite.config.js:
import{ defineConfig }from'vite'import{ defineConfig }from'vite';exportdefaultdefineConfig({build:{lib:{entry:'index.ts',name:'index',// UMD模块中库的全局名称,按需修改fileName:'index',},},});
- 修改之前生成的 package.json:
{"name":"ease-life","version":"1.0.0","description":"哥的幸福生活全靠你啦","scripts":{"dev":"vite --open","build":"vite build","preview":"vite preview --open"},"keywords":["monorepo","web components","pnpm","storybook","changeset"],"author":"zqc","repository":{"type":"git","url":""},"license":"MIT","type":"module","devDependencies":{"vite":"^5.2.0"},"engines":{"node":">= 18.0.0","pnpm":">= 9.0.0"}}
3.3.3 自定义 Web Components
- 在 packages/web-components/text/src 下创建 text.ts:
import{ html, css, LitElement }from'lit';import{ customElement, property }from'lit/decorators.js';@customElement('el-text')exportclassELTextextendsLitElement{static styles = css`p { color: blue }`;@property()
name ='Somebody';render(){return html`<p>Hello, ${this.name}!</p>`;}}
- 在 packages/web-components/text 下创建 index.ts(导出当前组件):
export{ ELText asdefault}from'./src/text.ts';
- 在 packages/web-components/text 下添加 tsconfig.json:
{"compilerOptions":{"target":"ESNext","experimentalDecorators":true,"useDefineForClassFields":false,"module":"ESNext","lib":["ES2020","DOM","DOM.Iterable"],"skipLibCheck":true,/* Bundler mode */"moduleResolution":"bundler","allowImportingTsExtensions":true,"resolveJsonModule":true,"isolatedModules":true,"noEmit":true,/* Linting */"strict":true,"noUnusedLocals":true,"noUnusedParameters":true,"noFallthroughCasesInSwitch":true},"include":["src"]}
以上内容将会被移至 packages/config/typescript 中,待修改
- 修改 在 packages/web-components/text 下的 package.json:
{"name":"@ease-life/text","version":"1.0.0","description":"","type":"module","files":["dist"],"main":"./dist/index.umd.cjs","module":"./dist/index.js","exports":{".":{"import":"./dist/index.js","require":"./dist/index.umd.cjs"}},"scripts":{"build":"vite build -c ../../../vite.config.js"},"keywords":["ELText"],"author":"","license":"ISC","dependencies":{"lit":"^3.1.2"}}
3.3.4 生成 storybook
- 在 apps/storybook 文件夹的路径下运行以下内容:
pnpm dlx storybook@latest init
选择最后一个选项,回车。
此时就会在 apps/storybook 下有对应的 storybook 的内容。
- 删除 apps/storybook/src/stories 下自带的 button.css、Button.stories.ts、Button.ts、header.css、Header.stories.ts、Header.ts、page.css、Page.stories.ts、Page.ts 六个文件。
3.3.5 添加 Lint、TS 配置(可选)
通过在 config 文件夹下添加一系列通用配置来满足 monorepo 仓库的需要,同时可以将其进行发布用在其他项目中。如需使用本文已经配置好的内容,则请移步:
- ESLint:https://www.npmjs.com/package/@ease-life/eslint-config
- Stylelint:https://www.npmjs.com/package/@ease-life/stylelint-config
- TypeScript:https://www.npmjs.com/package/@ease-life/typescript-config
- ESLint 添加 index.js:
/* eslint-disable no-underscore-dangle */import{ fixupConfigRules }from'@eslint/compat';import{ FlatCompat }from'@eslint/eslintrc';import eslint from'@eslint/js';// import eslintImport from 'eslint-plugin-import';import globals from'globals';import path from'path';import tseslint from'typescript-eslint';import{ fileURLToPath }from'url';const __filename =fileURLToPath(import.meta.url);const __dirname = path.dirname(__filename);const flatCompat =newFlatCompat({baseDirectory: __dirname,});exportdefault tseslint.config(
eslint.configs.recommended,...tseslint.configs.recommendedTypeChecked,...fixupConfigRules(flatCompat.extends('airbnb-base')),...flatCompat.extends('airbnb-typescript/base'),{languageOptions:{parserOptions:{project:true,tsconfigRootDir:import.meta.dirname,},},},{files:['**/*.{js,jsx,cjs,mjs}'],...tseslint.configs.disableTypeChecked,},{// 配置需要被忽略的文件,替代之前的 .eslintignore 文件ignores:['.idea','.vscode','**/dist/',],files:['**/*.{js,jsx,mjs,cjs,ts,ts,vue}'],},{// plugins: {// import: eslintImport, // 添加了 airbnb-base 就不用再加这个了// },settings:{'import/resolver':{typescript:{},},},languageOptions:{globals:{...globals.browser,// 'window' is not defined....globals.node,// 'process' is not defined.},},},{files:['eslint.config.js','vite.config.js','stylelint.config.js'],rules:{'import/no-extraneous-dependencies':'off',},},{rules:{'no-console': process.env.NODE_ENV==='production'?'warn':'off','no-debugger': process.env.NODE_ENV==='production'?'warn':'off','max-len':['error',200],},},);
修改 package.json:
{"name":"@ease-life/eslint-config","version":"1.1.0","description":"Custom airbnb eslint config with ESLint v9","main":"index.js","keywords":["eslint"],"author":"zqc","license":"MIT","type":"module","publishConfig":{"access":"public"},"dependencies":{"@eslint/compat":"1.1.0","@eslint/eslintrc":"3.1.0","@eslint/js":"9.4.0","eslint":"9.4.0","eslint-config-airbnb-base":"15.0.0","eslint-config-airbnb-typescript":"18.0.0","eslint-import-resolver-typescript":"3.6.1","eslint-plugin-import":"2.29.1","globals":"15.4.0","typescript-eslint":"7.13.0"}}
- Stylelint 添加 index.js:
exportdefault{// 继承已有配置 如果规则与自己想要的冲突 可以在下面rules中修改规则extends:['stylelint-config-standard','stylelint-config-standard-scss','stylelint-config-sass-guidelines',],// 插件是由社区创建的规则或规则集 按照规则对CSS属性进行排序plugins:[// 执行各种各样的 SCSS语法特性检测规则(插件包)'stylelint-scss',// 指定排序,比如声明的块内(插件包)属性的顺序'stylelint-order',],customSyntax:'postcss-html',rules:{// 允许的最大嵌套深度为 3'max-nesting-depth':3,'color-named':null,// 屏蔽一些scss等语法检查'at-rule-no-unknown':[true,{ignoreAtRules:['extend','at-root','debug','warn','error','if','else','for','each','while','mixin','include','content','return','function',],},],// 屏蔽没有申明通用字体'font-family-no-missing-generic-family-keyword':null,// ID选择器 最多使用一个'selector-max-id':1,// 不允许使用的选择器的类型'selector-no-qualifying-type':null,// 屏蔽类选择器的检查,以确保使用字符 __'selector-class-pattern':null,// 允许的最大复合选择器为 5'selector-max-compound-selectors':5,// 属性排序规则'order/properties-order':[['content','position','top','right','bottom','left','z-index','display','vertical-align','flex','flex-grow','flex-shrink','flex-basis','flex-direction','flex-flow','flex-wrap','grid','grid-area','grid-template','grid-template-areas','grid-template-rows','grid-template-columns','grid-row','grid-row-start','grid-row-end','grid-column','grid-column-start','grid-column-end','grid-auto-rows','grid-auto-columns','grid-auto-flow','grid-gap','grid-row-gap','grid-column-gap','gap','row-gap','column-gap','align-content','align-items','align-self','justify-content','justify-items','justify-self','order','float','clear','object-fit','overflow','overflow-x','overflow-y','overflow-scrolling','clip',//'box-sizing','width','min-width','max-width','height','min-height','max-height','margin','margin-top','margin-right','margin-bottom','margin-left','padding','padding-top','padding-right','padding-bottom','padding-left','border','border-spacing','border-collapse','border-width','border-style','border-color','border-top','border-top-width','border-top-style','border-top-color','border-right','border-right-width','border-right-style','border-right-color','border-bottom','border-bottom-width','border-bottom-style','border-bottom-color','border-left','border-left-width','border-left-style','border-left-color','border-radius','border-top-left-radius','border-top-right-radius','border-bottom-right-radius','border-bottom-left-radius','border-image','border-image-source','border-image-slice','border-image-width','border-image-outset','border-image-repeat','border-top-image','border-right-image','border-bottom-image','border-left-image','border-corner-image','border-top-left-image','border-top-right-image','border-bottom-right-image','border-bottom-left-image',//'background','background-color','background-image','background-attachment','background-position','background-position-x','background-position-y','background-clip','background-origin','background-size','background-repeat','color','box-decoration-break','box-shadow','outline','outline-width','outline-style','outline-color','outline-offset','table-layout','caption-side','empty-cells','list-style','list-style-position','list-style-type','list-style-image',//'font','font-weight','font-style','font-variant','font-size-adjust','font-stretch','font-size','font-family','src','line-height','letter-spacing','quotes','counter-increment','counter-reset','-ms-writing-mode','text-align','text-align-last','text-decoration','text-emphasis','text-emphasis-position','text-emphasis-style','text-emphasis-color','text-indent','text-justify','text-outline','text-transform','text-wrap','text-overflow','text-overflow-ellipsis','text-overflow-mode','text-shadow','white-space','word-spacing','word-wrap','word-break','overflow-wrap','tab-size','hyphens','interpolation-mode',//'opacity','visibility','filter','resize','cursor','pointer-events','user-select',//'unicode-bidi','direction','columns','column-span','column-width','column-count','column-fill','column-gap','column-rule','column-rule-width','column-rule-style','column-rule-color','break-before','break-inside','break-after','page-break-before','page-break-inside','page-break-after','orphans','widows','zoom','max-zoom','min-zoom','user-zoom','orientation','fill','stroke',//'transition','transition-delay','transition-timing-function','transition-duration','transition-property','transform','transform-origin','animation','animation-name','animation-duration','animation-play-state','animation-timing-function','animation-delay','animation-iteration-count','animation-direction','animation-fill-mode',],{unspecified:'bottom',severity:'warning',},],// 屏蔽属性按字母顺序检查'order/properties-alphabetical-order':null,},};
修改 package.json:
{"name":"@ease-life/stylelint-config","version":"1.1.0","description":"Custom stylelint","main":"index.js","keywords":["stylelint"],"author":"zqc","license":"MIT","type":"module","publishConfig":{"access":"public"},"peerDependencies":{"postcss-html":"1.7.0","stylelint":"16.6.1","stylelint-config-sass-guidelines":"11.1.0","stylelint-config-standard":"36.0.0","stylelint-config-standard-scss":"13.1.0","stylelint-order":"6.0.4","stylelint-scss":"6.3.1"}}
- TypeScript 添加 base.json:
{"compilerOptions":{"allowJs":true,"esModuleInterop":true,"experimentalDecorators":true,"isolatedModules":true,"lib":["ESNext","DOM"],"module":"ESNext","moduleResolution":"Bundler","noImplicitAny":true,"skipLibCheck":true,"strict":true,"target":"ESNext"},"exclude":["**/node_modules","**/dist"]}
修改 package.json:
{"name":"@ease-life/typescript-config","version":"1.1.1","description":"Custom tsconfig","keywords":["tsconfig"],"author":"zqc","license":"MIT","publishConfig":{"access":"public"}}
3.3.6 添加 changeset(可选)
主要用于流程化发布和在项目中生成 CHANGELOG.md。
pnpmadd @changesets/cli -Dw
3.3.7 添加其他
- 在项目跟目录下添加 .gitignore:
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo*.ntvs**.njsproj*.sln*.sw?
- 在项目根目录下创建 .editorconfig:
# 告诉编辑器这是最顶层的(不要再向上找了)
root = true
# All Files[*]
charset = utf-8# 设置编码为 utf-8
indent_style = space # 2 个空格的缩进
indent_size = 2
end_of_line = lf # Unix 风格的换行
insert_final_newline = true # 始终在文件末尾插入一个新行
trim_trailing_whitespace = true # 删除行尾空格
max_line_length = 200 # 单行最大宽度# Markdown 文件[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
- commitlint&husky&lintstaged
pnpmadd-Dw @commitlint/cli @commitlint/config-conventional husky lint-staged
echo"export default { extends: ['@commitlint/config-conventional'] };"> commitlint.config.js
pnpmexec husky init
打开 .husky/pre-commit 文件,删除其中的 “pnpm test” ,替换成:
pnpmexec lint-staged
继续执行:
echo"pnpm exec commitlint --edit \$1"> .husky/commit-msg
在根目录下的 package.json 中添加:
"lint-staged":{"*.{js,vue,ts}":["pnpm exec eslint --fix"],"*.{css,scss}":["pnpm exec stylelint --fix"]}
3.3.8 安装config中的内容并配置
pnpmadd-Dw @ease-life/eslint-config @ease-life/stylelint-config @ease-life/typescript-config
pnpmadd-Dw eslint lint-staged postcss-html stylelint stylelint-config-sass-guidelines stylelint-config-standard stylelint-config-standard-scss stylelint-order stylelint-scss
在项目根目录下创建 commitlint.config.js:
exportdefault{extends:['@commitlint/config-conventional']};
在项目根目录下创建 eslint.config.js:
import baseConfig from'@ease-life/eslint-config';exportdefault[...baseConfig,{// 屏蔽 web components 组件库的 eslint 规则files:['packages/web-components/**/*.ts','apps/storybook/src/my-element.ts'],rules:{'import/extensions':['error',{js:'always'},],'import/prefer-default-export':'off','no-restricted-exports':'off','@typescript-eslint/no-unsafe-assignment':'off','@typescript-eslint/no-unsafe-call':'off','@typescript-eslint/no-unsafe-return':'off','@typescript-eslint/indent':'off',},},];
在项目根目录下创建 stylelint.config.js:
import baseConfig from'@ease-life/stylelint-config';exportdefault{...baseConfig,};
在项目根目录下创建 tsconfig.json:
{"extends":"@ease-life/typescript-config/base.json","include":["packages/**/*.ts","apps/**/*.ts"],}
最终项目文件目录结构如下:
ease-life
|-- github
|-- .DS_Store
|-- .editorconfig
|-- .gitignore
|-- .npmrc
|-- README.md
|-- commitlint.config.js
|-- eslint.config.js
|-- package.json
|-- pnpm-lock.yaml
|-- pnpm-workspace.yaml
|-- stylelint.config.js
|-- tsconfig.json
|-- vite.config.js
|-- .changeset
| |-- README.md
| |-- config.json
|-- .husky
| |-- commit-msg
| |-- pre-commit
| |-- _
| |-- .gitignore
| |-- applypatch-msg
| |-- commit-msg
| |-- h
| |-- husky.sh
| |-- post-applypatch
| |-- post-checkout
| |-- post-commit
| |-- post-merge
| |-- post-rewrite
| |-- pre-applypatch
| |-- pre-auto-gc
| |-- pre-commit
| |-- pre-push
| |-- pre-rebase
| |-- prepare-commit-msg
|-- apps
| |-- .DS_Store
| |-- storybook
| |-- .DS_Store
| |-- .gitignore
| |-- index.html
| |-- package.json
| |-- tsconfig.json
| |-- .storybook
| | |-- main.ts
| | |-- preview.ts
| |-- public
| | |-- vite.svg
| |-- src
| |-- index.css
| |-- my-element.ts
| |-- vite-env.d.ts
| |-- assets
| | |-- lit.svg
| |-- stories
| |-- Configure.mdx
| |-- Text.stories.ts
| |-- assets
| |-- accessibility.png
| |-- accessibility.svg
| |-- addon-library.png
| |-- assets.png
| |-- avif-test-image.avif
| |-- context.png
| |-- discord.svg
| |-- docs.png
| |-- figma-plugin.png
| |-- github.svg
| |-- share.png
| |-- styling.png
| |-- testing.png
| |-- theming.png
| |-- tutorials.svg
| |-- youtube.svg
|-- packages
|-- config
| |-- eslint
| | |-- index.js
| | |-- package.json
| |-- stylelint
| | |-- index.js
| | |-- package.json
| |-- typescript
| |-- base.json
| |-- package.json
|-- web-components
|-- text
|-- index.ts
|-- package.json
|-- dist
| |-- index.js
| |-- index.umd.cjs
|-- src
|-- main.css
|-- text.ts
4 组件测试
- 在项目根目录下运行以下内容,来对 text 进行构建:
pnpm-F @ease-life/text build
-F 是 --filter 的简写
会在 packages/web-components/text 下生成 dist 文件夹,里边有 index.js(ESM) 以及 index.umd.cjs(CommonJS)。
- 在 apps/storybook/src/stories 下添加一个 Text.stories.ts:
importtype{ Meta, StoryObj }from'@storybook/web-components';import'@ease-life/text';const meta: Meta ={
component:'el-text'};exportdefault meta;typeStory= StoryObj;exportconst Default: Story ={
args:{
name:'world',},};
- 修改 apps/storybook 下的 package.json,将其中的 name 改为:
"name":"@ease-life/storybook",
- 在项目根目录下运行以下内容来安装刚才定义的 web components:
pnpm-F @ease-life/storybook add @ease-life/text
- 在项目根目录下运行以下内容,来启动 storybook:
pnpm-F @ease-life/storybook storybook
在浏览器中显示以下内容,则证明组件没有问题了。
5 组件发布
5.1 在 NPM 官网注册
如果没有注册过,则打开 NPM,点击右上角的 Sign Up,按提示填入信息。
5.2 登录账户
注册完后直接登录。
5.3 创建组织
打开创建组织的页面,在其中添加组织名称,组织名称就是 scope 的名称,也就是这里 @ 后面的内容。
@ease-life/tex,我这里创建了 ease-life 的组织。
5.3 组件发布
这里以发布至 NPM 为例。
5.3.1 用户登录
如之前没有发布过,在项目根目录下运行:
pnpm login
看到提示后,再次回车,在浏览器弹出的页面中进行登录,成功后显示以下内容:
5.3.2 组件发布
- 直接使用 PNPM,在项目根目录下运行:
pnpm publish -r
会自动发布仓库中版本发生改变的组件。
出现以上类似内容,就证明发布成功了。
- 结合 Changeset 做发布
pnpm changeset
通过键盘上下键切换到要发布的库,按空格后回车
选择版本,版本数字增加的是第一个、第二个还是第三个,通过回车跳过,按空格选中。
最后填写版本修改说明。再依次执行:
pnpm changeset version
确定版本
pnpm changeset publish
发布版本
版权归原作者 天下布武8 所有, 如有侵权,请联系我们删除。