赞
踩
Vue.js 代码经过编译后才能在浏览器运行,而且,Vue.js 代码编译后的结果就是基于非编译语法来运行的。
vue3代码编译过程主要进行了一下操作
Webpack 和 Vite 的定位是不一样的
Vite 定位是 Web“开发工具链”,其内置了一些打包构建工具,让开发者开箱即用,例如预设了 Web 开发模式直接使用 ESM 能力,开发过程中可以通过浏览器的 ESM 能力按需加载当前开发页面的相关资源。
Webpack 定位是构建“打包工具”,面向的是前端代码的编译打包过程。Webpack 能力很单一,就是提供一个打包构建的能力,如果有特定的构建需要,必须让开发者来选择合适的 Loader 和 Plugin 进行组合配置,达到最终的想要的打包效果。
安装依赖包
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"
}
}
第一步,项目目录和源码准备
.
├── 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; } }, }, });
定制化组件库可以更好的满足公司自己业务逻辑需求。作为前端工程师,你就必须掌握自研组件库的开发能力,为可能出现的定制化组件的要求做好准备。
分为三个技术要点
不同类型的组件可能存在互相依赖或者引用的关系,要保证能在一个代码仓库中快速调试多个 npm 模块的代码效果。一个仓库管理多个 npm 模块(多个子项目),就需要用到 monorepo 的项目管理形式。
必须支持组件库能够按需加载,使用将源码编译成 ES Module 和 CommonJS 格式。
利用 pnpm 天然支持 monorepo 的管理能力,同时 pnpm 安装 node_modules 也能更省体积空间。
代码编译分成以下三个步骤
编译 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.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:
用 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);
});
});
./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>
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。