从零开始实现Element Plus
- 前言
- 亮点
- 项目搭建
- 1、创建项目
- 初始化
- monorepo
- 创建 .gitignore
- 目录结构
- 安装基础依赖
- 配置文件
- 创建各个分包入口
- utils
- components
- core
- play
- theme
- 2、创建VitePress文档
- 3、部署到Github Actions
- 生成 GH_TOKEN
- GitHub Page 演示
- 4、总结
前言
在本文中,将手把手带你从零开始实现一个类似于Element Plus 的组件库。Element Plus 是一个非常流行的Vue UI 组件库,我们将尝试实现一些常见的组件,如基础组件、反馈组件、表单组件等。让我们开始吧!
亮点
- Vite+Vitest+Vitepress 工具链 (项目构建+测试+项目文档)
- monorepo 分包管理
- GitHub actions 实现 CI/CD 自动化部署
- 大模型辅助:使用大模型辅助完成需求分析,设计思路,快速实现组件,提升开发效率
- 发布
开箱即用
的npm包
项目搭建
1、创建项目
初始化
mkdir Wannaer-element
cd Wannaer-element
git init
pnpm init
monorepo
monorepo
,那就先创建一个 pnpm-workspace.yaml 文件。
mkdir packages
echo -e 'packages:\n - "packages/*"' > pnpm-workspace.yaml
// 在Windows系统中,echo命令默认不支持像在Linux系统中那样使用"-e"参数来表示换行符
// 创建完成后,手动操作换行
echo 'packages:\n - "packages/*"' > pnpm-workspace.yaml
// 如果出现 pnpm: null byte is not allowed in input (1:4) 可能是有隐藏字符问题
packages:
- "packages/*"
创建 .gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
coverage
dist
dist-ssr
*.local
/cyperss/videos/
/cypress/srceenshots/
.vitepress/dist
.vitepress/cache
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
目录结构
为了目录扁平,就只创建 packages 这么一个 pnpm 工作区,下面大概说一下这个项目计划的分包结构
- components # 组件目录
- core # npm 包入口
- docs # 文档目录
- hooks # 组合式API hooks 目录
- play # 组件开发实验室
- theme # 主题目录
- utils # 工具函数目录
// 创建这些目录
cd packages
// 分别初始化这些目录 在 packages 目录下创建 init.shell 内容如下
for i in components core docs hooks theme utils; do
mkdir $i
cd $i
pnpm init
cd ..
done
// 执行后删除 init.shell
这波 play 目录先留着,我们用 vite 来创建一个 vue 开发项目
pnpm create vite play --template vue-ts
创建完成后分别到 各个分包目录中修改 package.json 中的 name,防止重名
- core # npm 包入口
"name": "Wannaer-element",
- components # 组件目录
"name": "@Wannaer-element/components",
- docs # 文档目录
"name": "@Wannaer-element/docs",
- hooks # 组合式API hooks 目录
"name": "@Wannaer-element/hooks",
- play # 组件开发实验室
"name": "@Wannaer-element/play",
- theme # 主题目录
"name": "@Wannaer-element/theme",
- utils # 工具函数目录
"name": "@Wannaer-element/utils",
- 根目录
“name”: "@Wannaer-element/workspace"
安装基础依赖
在根目录 安装
-Dw表示在package.json文件中配置的scripts中运行特定的脚本命令,xxx为脚本命令的名称。
-w表示在指定的工作区目录中运行特定的脚本命令,xxx为脚本命令的名称。
// 开发依赖
pnpm add -Dw typescript@^5.2.2 vite@^5.1.4 vitest@^1.4.0 vue-tsc@^1.8.27 postcss-color-mix@^1.1.0 postcss-each@^1.1.0 postcss-each-variables@^0.3.0 postcss-for@^2.1.1 postcss-nested@^6.0.1 @types/node@^20.11.20 @types/lodash-es@^4.17.12 @vitejs/plugin-vue@^5.0.4 @vitejs/plugin-vue-jsx@^3.1.0 @vue/tsconfig@^0.5.1
// 非开发依赖
pnpm add -w lodash-es@^4.17.21 vue@^3.4.19
在 根目录 package.json 中添加如下内容 添加一下子包的依赖
{
"dependencies": {
"Wannaer-element": "workspace:*",
"@Wannaer-element/hooks": "workspace:*",
"@Wannaer-element/utils": "workspace:*",
"@Wannaer-element/theme": "workspace:*"
}
}
- components
pnpm add -D @vue/test-utils@^2.4.5 @vitest/coverage-v8@^1.4.0 jsdom@^24.0.0 --filter @Wannaer-element/components
pnpm add @popperjs/core@^2.11.8 async-validator@^4.2.5 --filter @Wannaer-element/components
- core
// 在 core/package.json 中添加如下内容
{
"dependencies": {
"@Wannaer-element/components": "workspace:*"
}
}
- docs
pnpm add -D vitepress@1.0.0-rc.44 --filter @Wannaer-element/docs
- play
将 play/package.json 中冗余部分删除, 并且删除掉tsconfig.json
和tsconfig.node.json
{
"name": "@Wannaer-element/play",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4"
}
}
配置文件
在根目录创建一些必要额配置文件,比如刚才删除play中的ts配置,我们在根目录配置
- tsconfig.json
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "vue",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["packages/**/*.ts", "packages/**/*.tsx", "packages/**/*.vue"]
}
- tsconfig.node.json
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["packages/**/**.config.ts"],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
- postcss.config.cjs
/* eslint-env node */
module.exports = {
plugins: [
require("postcss-nested"),
require("postcss-each-variables"),
require("postcss-each")({
plugins: {
beforeEach: [require("postcss-for"), require("postcss-color-mix")],
},
}),
],
};
配置完成后,重新安装一下依赖 pnpm install
执行之前更新的部分操作
创建各个分包入口
utils
在utils文件夹 新建一个文件 install.ts
用于 vue plugin 安装的一系列操作
import type { App, Plugin } from "vue";
import { each } from "lodash-es";
type SFCWithInstall<T> = T & Plugin;
export function makeInstaller(components: Plugin[]) {
const install = (app: App) =>
each(components, (c) => {
app.use(c);
});
return install;
}
export const withInstall = <T>(component: T) => {
(component as SFCWithInstall<T>).install = (app: App) => {
const name = (component as any)?.name || "UnnamedComponent";
app.component(name, component as SFCWithInstall<T>);
};
return component as SFCWithInstall<T>;
};
创建一个utils入口 index.ts 文件 用于导出utils所有方法
export * from "./install";
components
创建 index.ts
以及第一个基础组件 Button 组件目录
// index.ts
export * from './Button'
// Button 目录 Button.vue
<template>
<button style="color: red">this is a button</button>
</template>
<script setup lang="ts">
defineOptions({
name: "WButton",
});
</script>
<style scoped></style>
// Button 目录 index.ts
import Button from "./Button.vue";
import { withInstall } from "@Wannaer-element/utils";
export const WButton = withInstall(Button);
core
创建 index.ts 、components.ts
// components.ts
import { ErButton } from "@toy-element/components";
import type { Plugin } from "vue";
export default [ErButton] as Plugin[];
import { makeInstaller } from "@toy-element/utils";
import components from "./components";
const installer = makeInstaller(components);
export * from "@toy-element/components";
export default installer;
play
在main.ts 中 引入了我们刚刚写好的"Wannaer-element"的自定义元素库,并在App.vue中使用。
通过createApp(App).use(WElement).mount(“#app”)这行代码,将"Wannaer-element"库应用到了Vue实例中,并挂载到了id为"app"的DOM元素上。
在根目录的package.json中配置
"scripts": {
"dev": "pnpm --filter @Wannaer-element/play dev",
"test": "echo \"Error: no test specified\" && exit 1"
}
它定义了一个名为"dev"的脚本命令。在这个命令中,使用了pnpm工具,并通过"–filter @Wannaer-element/play"参数指定了要过滤的包,然后执行"dev"命令。这段代码的作用是在开发过程中使用pnpm工具来过滤特定的包并执行相应的开发命令。
配置完成后运行 pnpm dev
可以查看到我们刚刚封装好的 Button 虽然很简陋 接下来我们进行样式的修改,让他变得更加美观
theme
创建 index.css 、reset.css 在 theme/index.css 中导入 reset.css
/** index.css */
@import "./reset.css";
/** reset.css */
body {
font-family: var(--wan-font-family);
font-weight: 400;
font-size: var(--wan-font-size-base);
line-height: calc(var(--wan-font-size-base) * 1.2);
color: var(--wan-text-color-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
a {
color: var(--wan-color-primary);
text-decoration: none;
&:hovwan,
&:focus {
color: var(--wan-color-primary-light-3);
}
&:active {
color: var(--wan-color-primary-dark-2);
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--wan-text-color-regular);
font-weight: inhwanit;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
h1 {
font-size: calc(var(--wan-font-size-base) + 6px);
}
h2 {
font-size: calc(var(--wan-font-size-base) + 4px);
}
h3 {
font-size: calc(var(--wan-font-size-base) + 2px);
}
h4,
h5,
h6,
p {
font-size: inhwanit;
}
p {
line-height: 1.8;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
sup,
sub {
font-size: calc(var(--wan-font-size-base) - 1px);
}
small {
font-size: calc(var(--wan-font-size-base) - 2px);
}
hr {
margin-top: 20px;
margin-bottom: 20px;
bordwan: 0;
bordwan-top: 1px solid var(--wan-bordwan-color-lightwan);
}
最后改 package.json 中 入口为 index.css 在 core/index.ts 中导出我们的 theme
2、创建VitePress文档
可以直接参考官方文档
npx vitepress init
// 运行查看效果
pnpm docs:dev
我们改一下package.json指令 配置后统一可以从根目录运行
// docs目录 package.json
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
// 根目录 package.js
"scripts": {
"dev": "pnpm --filter @Wannaer-element/play dev",
"docs:dev": "pnpm --filter @Wannaer-element/docs dev",
"docs:build": "pnpm --filter @Wannaer-element/docs build",
"docs:preview": "pnpm --filter @Wannaer-element/docs preview",
"test": "echo \"Error: no test specified\" && exit 1"
},
接下来我们需要将 VitePress文档部署到 GitHub Actions
所以需要配置一下 docs目录下vitepress => config.mts 添加一个 base: “/wan-element”,解决部署后样式丢失问题
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Wan-Element",
description: "高仿 ElementPlus 组件库",
base: "/wan-element",
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: "Home", link: "/" },
{ text: "Examples", link: "/markdown-examples" },
],
sidebar: [
{
text: "Examples",
items: [
{ text: "Markdown Examples", link: "/markdown-examples" },
{ text: "Runtime API Examples", link: "/api-examples" },
],
},
],
socialLinks: [
{ icon: "github", link: "https://github.com/vuejs/vitepress" },
],
},
});
3、部署到Github Actions
创建一个 .github/workflows/deploy.yml 文件,内容如下
name: deploy
on:
push:
branches:
- master
jobs:
test:
name: Run Lint and Test
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: npm run test
build:
name: Build docs
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: npm run docs:build
- name: Upload docs
uses: actions/upload-artifact@v3
with:
name: docs
path: ./packages/docs/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: build
steps:
- name: Download docs
uses: actions/download-artifact@v3
with:
name: docs
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GH_TOKEN }}
publish_dir: .
secrets.GH_TOKEN 需要到Github 上面去生成
接下来去 github 创建一个仓库
复制仓库地址
https://github.com/Manba0/wan-element.git
git remote add origin https://github.com/Manba0/wan-element.git
git add .
git commit -m ":data: first commit"
生成 GH_TOKEN
最后将刚刚提交的代码 push到Github仓库
git push origin master
如果 push 出现一下报错
fatal: unable to access ‘https://github.com/XXXX/XXXX.git/’: Failed to connect to github.com port 443 after 21067 ms: Couldn’t connect to server
有可能你的gitbub之前设置过代理,只需分别执行如下代码即可:
git config --global --unset http.proxy
git config --global --unset https.proxy
提交成功后 发现 Settings 中Page 没有找到访问的链接,我们查看 Actions 发现 Run tests 没有通过, 因为我们根目录下 package.json 中的 test 指令 "test": "echo \"Error: no test specified\" && exit 1"
,修改成 "test": "echo 'todo'"
重新提交
这样就是成功了 我们直接去看Settings中的page https://manba0.github.io/wan-element/
GitHub Page 演示
4、总结
到此我们就已经全流程跑通了 接下来就是完善组件内容了。