0


Monorepo 模板 —— 使用 PNPM 从零搭建 Monorepo,测试 web components 并发布

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 相关

  1. 在项目根目录下添加文件:pnpm-workspace.yaml,定义 PNPM 的工作空间:
packages:# 匹配 packages 目录下(任意文件夹下)的所有模块-'packages/**'# 匹配 apps 直接子文件夹下的所有模块-'apps/*'

这里的模块,说的是:包含 package.json,可以被发布到 NPM 远程仓库的项目。

  1. 在项目根目录下添加文件:.npmrc,定义 PNPM 的配置项:
# 允许链接工作空间中的包
link-workspace-packages = true

# 在引用工作空间中的包时,设置前缀为 *,即:使用最新版本的包
save-prefix = ''

3.3.2 Vite 相关

  1. 在根目录下运行以下内容:
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",}
  1. 在 web-components、web-components/text、config/eslint、config/stylelint 以及 config/typescript 下都执行 pnpm init,或直接将根目录下的 package.json 拷贝过去。

本文的目的是要每个组件都能够被单独被发布至 NPM 仓库,如:@ease-life/text。如只需要做整个组件库的统一发布,则无需在 web-components/text 下执行 pnpm init。

  1. 在项目最外层空间下添加 vite:
pnpmadd vite -Dw

packages 里的所有模块如无特殊情况,可统一使用 vite 来运行、打包,因此只需要在项目最外层安装一次即可。

  1. 在项目根目录下,添加文件 vite.config.js:
import{ defineConfig }from'vite'import{ defineConfig }from'vite';exportdefaultdefineConfig({build:{lib:{entry:'index.ts',name:'index',// UMD模块中库的全局名称,按需修改fileName:'index',},},});
  1. 修改之前生成的 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

  1. 在 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>`;}}
  1. 在 packages/web-components/text 下创建 index.ts(导出当前组件):
export{ ELText asdefault}from'./src/text.ts';
  1. 在 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 中,待修改

  1. 修改 在 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

  1. 在 apps/storybook 文件夹的路径下运行以下内容:
pnpm dlx storybook@latest init

选择最后一个选项,回车。
在这里插入图片描述
此时就会在 apps/storybook 下有对应的 storybook 的内容。

  1. 删除 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 仓库的需要,同时可以将其进行发布用在其他项目中。如需使用本文已经配置好的内容,则请移步:

  1. 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"}}
  1. 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"}}
  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 添加其他

  1. 在项目跟目录下添加 .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?
  1. 在项目根目录下创建 .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
  1. 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 组件测试

  1. 在项目根目录下运行以下内容,来对 text 进行构建:
pnpm-F @ease-life/text build

-F 是 --filter 的简写

会在 packages/web-components/text 下生成 dist 文件夹,里边有 index.js(ESM) 以及 index.umd.cjs(CommonJS)。

  1. 在 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',},};
  1. 修改 apps/storybook 下的 package.json,将其中的 name 改为:
"name":"@ease-life/storybook",
  1. 在项目根目录下运行以下内容来安装刚才定义的 web components:
pnpm-F @ease-life/storybook add @ease-life/text
  1. 在项目根目录下运行以下内容,来启动 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 组件发布

  1. 直接使用 PNPM,在项目根目录下运行:
pnpm publish -r

会自动发布仓库中版本发生改变的组件。
在这里插入图片描述
出现以上类似内容,就证明发布成功了。

  1. 结合 Changeset 做发布
pnpm changeset

通过键盘上下键切换到要发布的库,按空格后回车
选择版本,版本数字增加的是第一个、第二个还是第三个,通过回车跳过,按空格选中。
最后填写版本修改说明。再依次执行:

pnpm changeset version

确定版本

pnpm changeset publish

发布版本

标签: monorepo pnpm storybook

本文转载自: https://blog.csdn.net/zapzqc/article/details/139619972
版权归原作者 天下布武8 所有, 如有侵权,请联系我们删除。

“Monorepo 模板 —— 使用 PNPM 从零搭建 Monorepo,测试 web components 并发布”的评论:

还没有评论