基础篇
vue3的编译和非编译模式
Vue.js 代码经过编译后才能在浏览器运行,而且,Vue.js 代码编译后的结果就是基于非编译语法来运行的。
vue3代码编译过程主要进行了一下操作
- 把 Vue.js 代码里的模板编译成基于 JavaScript 代码描述的 VNode(虚拟节点);
- 把 Vue.js 代码里 JavaScript 逻辑代码,编译成运行时对应生命周期的逻辑代码;
- 把内置的 CSS 样式代码抽离出来。
webpack/vite构建vue3项目
Webpack 和 Vite 的定位是不一样的
Vite 定位是 Web“开发工具链”,其内置了一些打包构建工具,让开发者开箱即用,例如预设了 Web 开发模式直接使用 ESM 能力,开发过程中可以通过浏览器的 ESM 能力按需加载当前开发页面的相关资源。
Webpack 定位是构建“打包工具”,面向的是前端代码的编译打包过程。Webpack 能力很单一,就是提供一个打包构建的能力,如果有特定的构建需要,必须让开发者来选择合适的 Loader 和 Plugin 进行组合配置,达到最终的想要的打包效果。
webpack配置
安装依赖包
npm i --save vue
npm i --save-dev css-loader mini-css-extract-plugin vue-loader webpack webpack-cli
添加webpack.config.js
const path = require("path");
const { VueLoaderPlugin } = require("vue-loader/dist/index");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
mode: "production",
entry: {
"index": path.join(__dirname, "src/index.js"),
},
output: {
path: path.join(__dirname, "dist"),
filename: "[name].js",
},
module: {
rules: [
{
test: /\.vue$/,
use: ["vue-loader"],
},
{
test: /\.(css|less)$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: "[name].css",
}),
],
externals: {
"vue": "window.Vue",
},
};
在package.json中添加命令配置
{
"scripts": {
"dev":"NODE_ENV=development webpack serve -c ./webpack.config.js",
"build":"NODE_ENV=production webpack -c ./webpack.config.js"
}
}
vite配置
第一步,项目目录和源码准备
.
├── dist
├── index.html
├── package.json
├── src
│ ├── app.vue
│ └── index.js
└── vite.config.js
第二步,安装依赖
npm i --save vue
npm i --save-dev vite @vitejs/plugin-vue
第三步,配置 Vite 的 Vue.js 3 编译配置,也就是在 vite.config.js 配置 Vite 的编译配置
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "./",
});
第四步,package.json 配置开发模式和生产模式的脚本命令。
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}
Vite 根据访问页面引用的 ESM 类型的 JavaScript 文件进行查找依赖,并将依赖通过 esbuild 编译成 ESM 模块的代码,保存在 node_modules/.vite/ 目录下;
浏览器的 ESM 加载特性会根据页面依赖到 ESM 模块自动进行按需加载。
- 再次修改代码,再次访问页面,会自动执行 ESM 按需加载,同时触发依赖到的变更文件重新单独编译;
- 修改代码只会触发刷新页面,不会直接触发代码编译,而且源码编译是浏览器通过 ESM 模块加载访问到对应文件才进行编译的;
- 开发模式下因为项目源码是通过 esbuild 编译,所以速度比 Rollup 快,同时由于是按页面里请求依赖进行按需编译,所以整体打包编译速度理论上是比 Rollup 快一些。
组件间的通信
props:父组件向子组件单向传递数据
emits:子组件的数据传递给父组件
子组件代码:
<template>
<div class="v-text">
<span>
地址:
</span>
<input :value="props.text" @ input="onInput" />
</div>
</template>
<script setup>
const props = defineProps({
text: String,
});
const emits = defineEmits(["onChangeText"]);
const onInput = (e) => {
emits("onChangeText", e.target.value);
};
</script>
父组件代码
<template>
<div>订单信息:{{ text }}</div>
<div class="app">
<v-text v-bind:text="text" v-on:onChangeText="onChangeText" />
</div>
</template>
<script setup>
import { ref } from "vue";
import VText from "./text.vue";
const text = ref("888号");
const onChangeText = (newText) => {
text.value = newText;
};
</script>
课程完整资料库
多层级跨组件传递数据,使用Pinia
Pinia 就是一个基于 Proxy 实现的 Vue.js 公共状态数据管理的 JavaScript 库,可以提供组件间的数据通信
Pinia 可以定义一个公共的数据 store,在这个公共数据里管理多个数据的操作和计算。各个组件,无论是父子组件关系还是兄弟组件管理,都基于这个 store 来进行读数据展示和写数据更新状态,读写过程都是分开管理。读数据基于内置的 Getter 和 State 属性,写数据基于内部的 Action 方法。
import { defineStore } from "pinia";
export const useMyStore = defineStore("my-store", {
state: () => ({
text: "888号",
list: [
{
name: "苹果",
price: 20,
count: 0,
},
{
name: "香蕉",
price: 12,
count: 0,
},
{
name: "梨子",
price: 15,
count: 0,
},
],
}),
getters: {
totalPrice(state) {
let total = 0;
state.list.forEach((item) => {
total += item.price * item.count;
});
return;
total;
},
},
actions: {
updateText(text) {
this.text = text;
},
increase(index) {
this.list[index].count += 1;
},
decrease(index) {
if (this.list[index].count > 0) {
this.list[index].count -= 1;
}
},
},
});
课程完整资料库
搭建自研组件库
定制化组件库可以更好的满足公司自己业务逻辑需求。作为前端工程师,你就必须掌握自研组件库的开发能力,为可能出现的定制化组件的要求做好准备。
分为三个技术要点
- monorepo 管理组件代码;
- Vue.js 3.x 源文件的多种模块格式编译;
- 基于 Less 开发 CSS 样式文件和独立编译。
不同类型的组件可能存在互相依赖或者引用的关系,要保证能在一个代码仓库中快速调试多个 npm 模块的代码效果。一个仓库管理多个 npm 模块(多个子项目),就需要用到 monorepo 的项目管理形式。
必须支持组件库能够按需加载,使用将源码编译成 ES Module 和 CommonJS 格式。
如何搭建monorepo项目
利用 pnpm 天然支持 monorepo 的管理能力,同时 pnpm 安装 node_modules 也能更省体积空间。
- 初始化代码目录;
- 基于 pnpm 配置 monorepo 项目;
- 安装所有子项目依赖。
业务组件库子项目(@my/business)里依赖了基础组件库的子项目(@my/components),通过 pnpm 管理的 monorepo 项目方式,将依赖的 @my/components 子项目通过“软链接”形式指向了真正的 components/* 目录。
对组件库做编译设置
代码编译分成以下三个步骤
- 编译 TypeScript 和 Vue.js 3.x 源码为 ES Module 和 CommonJS 模块的两种 JavaScript 代码文件;
- 编译出所有 JavaScript 文件的 TypeScript 类型描述文件
- 把文件编译 Less 成 CSS 文件
编译 TypeScript 和 Vue.js 3.x 源码成 ES Module 和 CommonJS 模块的两种 JavaScript 代码文件。在项目的 scripts/* 目录下编写以下编译脚本
脚本文件是 scripts/build-module.ts
import fs from "node:fs";
import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import VueMacros from "unplugin-vue-macros/rollup";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import esbuild from "rollup-plugin-esbuild";
import glob from "fast-glob";
import type { OutputOptions } from "rollup";
import { resolvePackagePath } from "./util";
const getExternal = async (pkgDirName: string) => {
const pkgPath = resolvePackagePath(pkgDirName, "package.json");
const manifest = require(pkgPath) as any;
const {
dependencies = {},
peerDependencies = {},
devDependencies = {},
} = manifest;
const deps: string[] = [
...new Set([
...Object.keys(dependencies),
...Object.keys(peerDependencies),
...Object.keys(devDependencies),
]),
];
return (id: string) => {
if (id.endsWith(".less")) {
return true;
}
return deps.some((pkg) => id === pkg || id.startsWith(`${pkg}/`));
};
};
const build = async (pkgDirName: string) => {
const pkgDistPath = resolvePackagePath(pkgDirName, "dist");
if (fs.existsSync(pkgDistPath) && fs.statSync(pkgDistPath).isDirectory()) {
fs.rmSync(pkgDistPath, {
recursive: true,
});
}
const input = await glob(["**/*.{js,jsx,ts,tsx,vue}", "!node_modules"], {
cwd: resolvePackagePath(pkgDirName, "src"),
absolute: true,
onlyFiles: true,
});
const bundle = await rollup({
input,
plugins: [
VueMacros({
setupComponent: false,
setupSFC: false,
plugins: {
vue: vue({
isProduction: true,
}),
vueJsx: vueJsx(),
},
}),
nodeResolve({
extensions: [".mjs", ".js", ".json", ".ts"],
}),
commonjs(),
esbuild({
sourceMap: true,
target: "es2015",
loaders: {
".vue": "ts",
},
}),
],
external: await getExternal(pkgDirName),
treeshake: false,
});
const options: OutputOptions[] = [
// CommonJS 模块格式的编译
{
format: "cjs",
dir: resolvePackagePath(pkgDirName, "dist", "cjs"),
exports: "named",
preserveModules: true,
preserveModulesRoot: resolvePackagePath(pkgDirName, "src"),
sourcemap: true,
entryFileNames: "[name].cjs",
},
// ES Module 模块格式的编译
{
format: "esm",
dir: resolvePackagePath(pkgDirName, "dist", "esm"),
exports: undefined,
preserveModules: true,
preserveModulesRoot: resolvePackagePath(pkgDirName, "src"),
sourcemap: true,
entryFileNames: "[name].mjs",
},
];
return Promise.all(options.map((option) => bundle.write(option)));
};
console.log("[TS] 开始编译所有子模块···");
await build("components");
await build("business");
console.log("[TS] 编译所有子模块成功!");
编译出ts文件,需要脚本文件是 scripts/build-dts.ts,
import process from 'node:process'
import path from 'node:path';
import fs from 'node:fs'
import * as vueCompiler from 'vue/compiler-sfc'
import glob from 'fast-glob';
import { Project } from 'ts-morph'
import type { CompilerOptions, SourceFile } from 'ts-morph'
import { resolveProjectPath, resolvePackagePath } from './util';
const tsWebBuildConfigPath = resolveProjectPath('tsconfig.web.build.json');
// 检查项目的类型是否正确
function checkPackageType(project: Project) {
const diagnostics = project.getPreEmitDiagnostics();
if (diagnostics.length > 0) {
console.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
const err = new Error('TypeScript类型描述文件构建失败!')
console.error(err)
throw err
}
}
// 将*.d.ts文件复制到指定格式模块目录里
async function copyDts(pkgDirName: string) {
const dtsPaths = await glob(['**/*.d.ts'], {
cwd: resolveProjectPath('dist', 'types', 'packages', pkgDirName, 'src'),
absolute: false,
onlyFiles: true,
});
dtsPaths.forEach((dts: string) => {
const dtsPath = resolveProjectPath('dist', 'types', 'packages', pkgDirName, 'src', dts)
const cjsPath = resolvePackagePath(pkgDirName, 'dist', 'cjs', dts);
const esmPath = resolvePackagePath(pkgDirName, 'dist', 'esm', dts);
const content = fs.readFileSync(dtsPath, { encoding: 'utf8' });
fs.writeFileSync(cjsPath, content);
fs.writeFileSync(esmPath, content);
});
}
// 添加源文件到项目里
async function addSourceFiles(project: Project, pkgSrcDir: string) {
project.addSourceFileAtPath(resolveProjectPath('env.d.ts'))
const globSourceFile = '**/*.{js?(x),ts?(x),vue}'
const filePaths = await glob([globSourceFile], {
cwd: pkgSrcDir,
absolute: true,
onlyFiles: true,
})
const sourceFiles: SourceFile[] = []
await Promise.all([
...filePaths.map(async (file) => {
if (file.endsWith('.vue')) {
const content = fs.readFileSync(file, { encoding: 'utf8' })
const hasTsNoCheck = content.includes('@ts-nocheck')
const sfc = vueCompiler.parse(content)
const { script, scriptSetup } = sfc.descriptor
if (script || scriptSetup) {
let content =
(hasTsNoCheck ? '// @ts-nocheck\n' : '') + (script?.content ?? '')
if (scriptSetup) {
const compiled = vueCompiler.compileScript(sfc.descriptor, {
id: 'temp',
})
content += compiled.content
}
const lang = scriptSetup?.lang || script?.lang || 'js'
const sourceFile = project.createSourceFile(
`${path.relative(process.cwd(), file)}.${lang}`,
content
)
sourceFiles.push(sourceFile)
}
} else {
const sourceFile = project.addSourceFileAtPath(file)
sourceFiles.push(sourceFile)
}
}),
])
return sourceFiles
}
// 生产Typescript类型描述文件
async function generateTypesDefinitions(
pkgDir: string,
pkgSrcDir: string,
outDir: string
){
const compilerOptions: CompilerOptions = {
emitDeclarationOnly: true,
outDir,
}
const project = new Project({
compilerOptions,
tsConfigFilePath: tsWebBuildConfigPath
})
const sourceFiles = await addSourceFiles(project, pkgSrcDir)
checkPackageType(project);
await project.emit({
emitOnlyDtsFiles: true,
})
const tasks = sourceFiles.map(async (sourceFile) => {
const relativePath = path.relative(pkgDir, sourceFile.getFilePath())
const emitOutput = sourceFile.getEmitOutput()
const emitFiles = emitOutput.getOutputFiles()
if (emitFiles.length === 0) {
throw new Error(`异常文件: ${relativePath}`)
}
const subTasks = emitFiles.map(async (outputFile) => {
const filepath = outputFile.getFilePath()
fs.mkdirSync(path.dirname(filepath), {
recursive: true,
});
})
await Promise.all(subTasks)
})
await Promise.all(tasks)
}
async function build(pkgDirName) {
const outDir = resolveProjectPath('dist', 'types');
const pkgDir = resolvePackagePath(pkgDirName);
const pkgSrcDir = resolvePackagePath(pkgDirName, 'src');
await generateTypesDefinitions(pkgDir, pkgSrcDir, outDir);
await copyDts(pkgDirName);
}
console.log('[Dts] 开始编译d.ts文件···')
await build('components');
await build('business');
console.log('[Dts] 编译d.ts文件成功!')
编译样式文件less到css,编译脚本文件是 scripts/build-css.ts
import fs from 'node:fs';
import path from 'node:path';
import glob from 'fast-glob';
import less from 'less';
import { resolvePackagePath, wirteFile } from './util';
function compileLess(file: string): Promise<string> {
return new Promise((resolve, reject) => {
const content = fs.readFileSync(file, { encoding: 'utf8' });
less.render(content, {
paths: [ path.dirname(file) ],
filename: file,
plugins: [],
javascriptEnabled: true
}).then((result) => {
resolve(result.css);
}).catch((err) => {
reject(err);
})
})
}
async function build(pkgDirName: string) {
const pkgDir = resolvePackagePath(pkgDirName, 'src');
const filePaths = await glob(['**/style/index.less'], {
cwd: pkgDir,
});
const indexLessFilePath = resolvePackagePath(pkgDirName, 'src', 'index.less');
if (fs.existsSync(indexLessFilePath)) {
filePaths.push('index.less')
}
for (let i = 0; i < filePaths.length; i ++) {
const file = filePaths[i];
const absoluteFilePath = resolvePackagePath(pkgDirName, 'src', file);
const cssContent = await compileLess(absoluteFilePath);
const cssPath = resolvePackagePath(pkgDirName, 'dist', 'css', file.replace(/.less$/, '.css'));
wirteFile(cssPath, cssContent);
}
}
console.log('[CSS] 开始编译Less文件···')
await build('components');
await build('business');
console.log('[CSS] 编译Less成功!')
组件库开发的三个要素
-
用 monorepo 管理多种类型组件库,这类项目的代码管理方式,可以一个仓库同时聚合管理多个项目,让项目之间代码依赖使用更方便;
-
源码要编译成多种模块格式(CommonJS 和 ES Module),主要考虑到前端代码 npm 模块的时候,目前主流是 ES
Module 模块格式,但还是存在很多传统的 CommonJS 模块格式的使用兼容。所以在开发自研组件库的时候,尽量要考虑这两种模块格式; -
基于 Less 等预处理 CSS 语言来开发组件库的样式,由于 CSS 语言能力有限,无法像 JavaScript
那样可以使用各种编程逻辑和特性,所以需要借助 CSS 预处理语言进行开发 CSS。
动态渲染组件
动态渲染组件就是通过“动态”的方式来“渲染”组件,不需要像常规 Vue.js 3.x 组件那样,把组件注册到模板里使用。
动态渲染组件的两个技术特点
- 以直接函数式地使用来执行渲染,使用者不需要写代码来挂载组件
- 组件内部实现了动态挂载和卸载节点的操作。
Vue.js 3.x 动态渲染组件在页面上是独立于“Vue.js 主应用”之外的渲染。
动态渲染组件整个生命周期,最核心的就是“动态挂载”和“动态卸载”两个步骤
动态组件在其生命周期,可以这么来设计
import { Module } from 'xxxx'
// 创建动态组件 mod1
const mod1 = Module.create({ /* 组件参数 */ });
// 挂载渲染 mod1
mod1.open();
// 更新组 mod1 件内容
mod1.update({ /* 更新内容参数 */ })
// 卸载动态组件 mod1
mod1.close();
用最简单的 Vue.js 3.x 代码实现
import { defineComponent, createApp, h } from 'vue';
// 用 JSX 语法实现一个Vue.js 3.x的组件
const ModuleComponent = defineComponent({
setup(props, context) {
return () => {
return (
<div>这是一个动态渲染的组件</div>
);
};
}
});
// 实现动态渲染组件的过程
export const createModule = () => {
// 创建动态节点DOM
const dom = document.createElement('div');
// 把 DOM 追加到页面 body标签里
const body = document.querySelector('body') as HTMLBodyElement;
const app = createApp({
render() {
return h(DialogComponent, {});
}
});
// 返回当前组件的操作实例
// 其中封装了挂载和卸载组件的方法
return {
open(): () => {
// 把组件 ModuleComponent 作为一个独立应用挂载在 DOM 节点上
app.mount(dom);
},
close: () => {
// 卸载组件
app.unmount();
// 销毁动态节点
dom.remove();
}
}
}
上面实现的组件可以这样使用
import { createModule } from './xxxx';
// 创建和渲染组件
const mod = createModule();
// 挂载渲染组件
mod.open();
// 卸载关闭组件
mod.close();
实现Dialog 组件
// ./dialog.tsx
import { defineComponent } from 'vue';
import { prefixName } from '../theme/index';
export const DialogComponent = defineComponent({
props: {
text: String
},
emits: ['onOk'],
setup(props, context) {
const { emit } = context;
const onOk = () => {
emit('onOk');
};
return () => {
return (
<div class={`${prefixName}-dialog-mask`}>
<div class={`${prefixName}-dialog`}>
<div class={`${prefixName}-dialog-text`}>{props.text}</div>
<div class={`${prefixName}-dialog-footer`}>
<button class={`${prefixName}-dialog-btn`} onClick={onOk}>
确定
</button>
</div>
</div>
</div>
);
};
}
});
以下是封装了函数方法调用的动态渲染组件的方式
import { createApp, h } from 'vue';
import { DialogComponent } from './dialog';
function createDialog(params: { text: string; onOk: () => void }) {
const dom = document.createElement('div');
const body = document.querySelector('body') as HTMLBodyElement;
body.appendChild(dom);
const app = createApp({
render() {
return h(DialogComponent, {
text: params.text,
onOnOk: params.onOk
});
}
});
app.mount(dom);
return {
close: () => {
app.unmount();
dom.remove();
}
};
}
const Dialog: { createDialog: typeof createDialog } = {
createDialog
};
export default Dialog;
代码单元测试
单元测试,英文是 Unit Test,也可以称之为“模块测试”,主要是对代码最小单位逐一进行测试验证功能。这里的“代码最小单位”可以是一个函数、一个组件、一个类,甚至是一个变量。只要是能执行功能的代码模块,都可以称之为一个“最小单位”。
市面支持测试“断言”或“测试管理”的主流前端 JavaScript 单元测试工具,有 Mocha、Jest 和 Vitest:
- Mocha是面向 Node.js 环境的 JavaScript 单元测试,不能直接支持浏览器的 API,断言可以使用 Node.js 自带 assert 模块或者第三方断言工具,例如 Chai;
- Jest是同时支持 Node.js 和在 Node.js 里模拟浏览器 API 的测试工具,内部自带测试“断言”和“管理”工具,是 React.js 官方维护的测试工具。
- Vitest跟 Jest 一样,都能支持 Node.js 和浏览器 API,也自带测试“断言”和“管理”工具,是 Vue.js 官方维护的测试工具,对 Vue.js 的支持能力比较友好。
用 Vitest,给 Vue.js 3.x 组件库做单元测试
安装依赖
npm i -D vitest @vue/test-utils @vitejs/plugin-vue @vitejs/plugin-vue-jsx jsdom
pnpm i -D vitest @vue/test-utils @vitejs/plugin-vue @vitejs/plugin-vue-jsx jsdom
vitest.config.js配置文件
import { defineConfig } from 'vitest/config';
import PluginVue from '@vitejs/plugin-vue';
import PluginJsx from '@vitejs/plugin-vue-jsx';
export default defineConfig({
plugins: [PluginVue(), PluginJsx()],
test: {
globals: true,
environment: 'jsdom',
coverage: {
// 覆盖率统计工具
provider: 'c8',
// 覆盖率的分母,packages/ 目录里
// 所有src的源文件作为覆盖率统计的分母
include: ['packages/*/src/**/*'],
// 全量覆盖率计算
all: true
}
}
});
新建文件./packages/components/tests/demo.test.ts,小试一下单元测试
import { describe, test, expect } from 'vitest';
describe('Demo', () => {
test('Test case', () => {
const a = 1;
const b = 2;
expect(a + b).toBe(3);
});
});
添加button行为测试
./packages/components/tests/button/index.test.ts文件中
import { describe, test, expect } from 'vitest';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import ButtonTest from './index.test.vue';
describe('Button', () => {
test('click event', async () => {
const wrapper = mount(ButtonTest, { props: { num: 123 } });
const textDOM = wrapper.find('.display-text');
const btnDOM = wrapper.find('.btn-add');
expect(textDOM.text()).toBe('当前数值=123');
btnDOM.trigger('click');
await nextTick();
expect(textDOM.text()).toBe('当前数值=124');
});
});
单元测试验证代码
<template>
<div class="display-text">当前数值={{ num }}</div>
<Button class="btn-add" @click="onClick">点击加1</Button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Button } from '../../src';
const props = defineProps<{ num: number }>();
const num = ref<number>(props.num);
const onClick = () => {
num.value++;
};
</script>
课程完整资料库