Vue3企业级项目开发实战课-笔记记录

基础篇

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>

课程完整资料库

  • 12
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值