用vite的方式开发electron应用

用vite的方式开发electron应用

vite的构建方式让前端人员的编程体验好了太多,最近在学习electron应用的开发,就在想能不能使用vite的方式开发electron应用。看了很多方案,大部分都是基于webpack的脚手架。

那么有没有一种方式能够将vite结合electron,来开发electron应用呢?答案当然是有的,作为electronvite整合开源方案中最火的项目:vite-plugin-electron 。本文将基于这个项目的实现思路,详细记录如何编写vite插件并在最终手写一个vite插件,实现用vite的方式开发electron应用这么一个小目标。

阅读本文前你需要对vite有一个基本的认识,否则你将对一些内容感到一头雾水。如果你对vite的插件开发有兴趣的话,请一定耐心阅读完本文,干货满满。

在本文中我将提到以下几点:

  • vite插件的基础知识与简单应用
  • electron应用开发的入门
  • vite整合electron应用开发的思路
  • 编写vite插件实现viteelectron应用的整合

vite插件的基础知识与简单应用

vite插件的用途简单来说就是帮助我们在vite构建的不同生命周期中执行我们需要的业务逻辑,这有时候对我们很重要。vite针对这些生命周期暴露出了很多对应的生命周期函数钩子,我们只需要实现这些钩子函数即可。

vite的生命周期

vite的生命周期分为两种:rollup的生命周期和vite特有的生命周期。

通用钩子

我们知道vite项目打包时底层依赖的是rollup,而rollup打包过程是有自己的一套生命周期的,vite为了与其保持一致,故保留了相应的生命周期钩子,这些称作通用钩子。

服务启动时被调用:

  • options:这是构建阶段的第一个钩子,用于替换或操作传递给 rollup.rollup 的选项对象
  • buildStart:可获取rollup.rollup 的选项对象

传入每个模块请求时被调用:

  • resolveId
  • load
  • transform

服务器关闭时被调用:

  • buildEnd:在 Rollup 完成产物但尚未调用 generatewrite 之前调用
  • closeBundlebundle.close()后最后一个触发的钩子,一般可用于清理可能正在运行的任何外部服务
vite特用的钩子
config

在解析 vite配置前调用,它可以返回一个将被深度合并到现有配置中的部分配置对象,或者直接改变配置(如果默认的合并不能达到预期的结果)。

// 返回部分配置(推荐)
const partialConfigPlugin = () => ({
  name: 'return-partial',
  config: () => ({
    resolve: {
      alias: {
        foo: 'bar',
      },
    },
  }),
})

// 直接改变配置(应仅在合并不起作用时使用)
const mutateConfigPlugin = () => ({
  name: 'mutate-config',
  config(config, { command }) {
    if (command === 'build') {
      config.root = 'foo'
    }
  },
})
configResolved

在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。

const examplePlugin = () => {
  let config

  return {
    name: 'read-config',

    configResolved(resolvedConfig) {
      // 存储最终解析的配置
      config = resolvedConfig
    },

    // 在其他钩子中使用存储的配置
    transform(code, id) {
      if (config.command === 'serve') {
        // dev: 由开发服务器调用的插件
      } else {
        // build: 由 Rollup 调用的插件
      }
    },
  }
}
configureServer

是用于配置开发服务器的钩子,最常见的用例是在内部 connect 应用程序中添加自定义中间件。

connect应用程序是一个中间件层,可往其中添加很多中间件

中间件可简单理解为一个函数或拦截器,请求在进入正式的业务逻辑前,会先被**中间件链(拦截器链)**处理。

const myPlugin = () => ({
  name: 'configure-server',
  configureServer(server) {
    server.middlewares.use((req, res, next) => {
      // 自定义请求处理...
    })
  },
})
其余不常用的钩子
  • configurePreviewServer
  • transformIndexHtml
  • handleHotUpdate

vite的简单应用

首先通过vite的官方模板创建一个vite项目

npm create vite@latest

编写一个简单的插件,插件的作用只是在各个生命周期钩子被调用时打印内容和参数,代码如下:

// 文件名为:vite-plugin-featureTest.ts
import { Plugin } from "vite";
interface FeatureTestOption {

}

export default (option: FeatureTestOption): Plugin => {

    return {
        name: 'featureTest',
        options: (curOpt) => {
            console.log('通用钩子options被调用!参数为:',curOpt)
            console.log('==========================================================')
        },
        buildStart:(curOpt)=>{
            console.log('通用钩子buildStart被调用!参数为:',curOpt)
            console.log('==========================================================')
        },
        buildEnd:()=>{
            console.log('通用钩子buildEnd被调用!')
            console.log('==========================================================')
        },
        closeBundle:()=>{
            console.log('通用钩子closeBundle被调用!')
            console.log('==========================================================')
        },
        config:(cfg,env)=>{
            console.log('vite特有的钩子config被调用!参数config为:',cfg,'参数env为:',env)
            console.log('==========================================================')
        },
        configResolved:(cfg)=>{
            console.log('vite特有的钩子configResolve被调用!参数config为:',cfg)
            console.log('==========================================================')
        },
        configureServer:(server)=>{
            console.log('vite特有的钩子configureServer被调用!参数server:',server)
            console.log('==========================================================')
        }
    }
}

我们使用typesecipt进行开发来获取更好的代码提示。开发一个插件其实很简单,就是要定义一个类型为Plugin的对象,但是为了更好的扩展性,插件约定俗成的写法是通过函数返回Plugin类型的对象,同时函数接收一个插件参数对象。

生命周期的钩子在Plugin类型对象中都有一一对应的属性,属性值为一个函数,我们的工作就是编写这些函数。

接下来,我们要在vite配置中引入我们编写的插件

// 文件名:vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import myVitePlugin from './plugins/vite-plugin-featureTest'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    myVitePlugin({})
  ],
})

plugins数组中调用插件暴露的函数即可。

最后观察结果:

PS C:\Users\huanghe\others\vscode-projects\learnElectronAndVite\vite-project> npm run dev

> vite-project@0.0.0 dev
> vite

vite特有的钩子config被调用!参数config为: {       
  plugins: [
    {
      name: 'vite:vue',
      api: [Object],
      handleHotUpdate: [Function: handleHotUpdate],
      config: [Function: config],
      configResolved: [Function: configResolved],  
      configureServer: [Function: configureServer],
      buildStart: [Function: buildStart],
      resolveId: [AsyncFunction: resolveId],       
      load: [Function: load],
      transform: [Function: transform]
    },
    ....省略...
} 
参数env为: {
  mode: 'development',
  command: 'serve',
  isSsrBuild: false,
  isPreview: false
}
==========================================================
vite特有的钩子configResolve被调用!参数config为: {
  
  ....省略....
}
==========================================================
通用钩子options被调用!参数为: {}
==========================================================
vite特有的钩子configureServer被调用!参数server: {
  
  ...省略...
}
==========================================================
通用钩子buildStart被调用!参数为: {}
==========================================================

  VITE v5.0.10  ready in 659 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

从最后的结果中我们可以发现,我们定义的函数分别在vite构建过程的不同阶段被调用。

有个特别的点我专门记录下:

configResolved阶段能获取最终的config,从中我们发现了很多不是我们配置的plugin,这些是vite帮我们注入的。

plugins: [
    {
      name: 'vite:optimized-deps',
      resolveId: [Function: resolveId],
      load: [AsyncFunction: load]
    },
    {
      name: 'vite:watch-package-data',
      buildStart: [Function: buildStart],
      buildEnd: [Function: buildEnd],
      watchChange: [Function: watchChange],
      handleHotUpdate: [Function: handleHotUpdate]
    },
    { name: 'vite:pre-alias', resolveId: [AsyncFunction: resolveId] },
    {
      name: 'alias',
      buildStart: [AsyncFunction: buildStart],
      resolveId: [Function: resolveId]
    },
    {
      name: 'vite:modulepreload-polyfill',
      resolveId: [Function: resolveId],
      load: [Function: load]
    },
    {
      name: 'vite:resolve',
      resolveId: [AsyncFunction: resolveId],
      load: [Function: load]
    },
    {
      name: 'vite:html-inline-proxy',
      resolveId: [Function: resolveId],
      load: [Function: load]
    },
    {
      name: 'vite:css',
      configureServer: [Function: configureServer],
      buildStart: [Function: buildStart],
      transform: [AsyncFunction: transform]
    },
    {
      name: 'vite:esbuild',
      configureServer: [Function: configureServer],
      buildEnd: [Function: buildEnd],
      transform: [AsyncFunction: transform]
    },
    { name: 'vite:json', transform: [Function: transform] },
    {
      name: 'vite:wasm-helper',
      resolveId: [Function: resolveId],
      load: [AsyncFunction: load]
    },
    {
      name: 'vite:worker',
      configureServer: [Function: configureServer],
      buildStart: [Function: buildStart],
      load: [Function: load],
      shouldTransformCachedModule: [Function: shouldTransformCachedModule],
      transform: [AsyncFunction: transform],
      renderChunk: [Function: renderChunk],
      generateBundle: [Function: generateBundle]
    },
    {
      name: 'vite:asset',
      buildStart: [Function: buildStart],
      configureServer: [Function: configureServer],
      resolveId: [Function: resolveId],
      load: [AsyncFunction: load],
      renderChunk: [Function: renderChunk],
      generateBundle: [Function: generateBundle]
    },
    {
      name: 'vite:vue',
      api: [Object],
      handleHotUpdate: [Function: handleHotUpdate],
      config: [Function: config],
      configResolved: [Function: configResolved],
      configureServer: [Function: configureServer],
      buildStart: [Function: buildStart],
      resolveId: [AsyncFunction: resolveId],
      load: [Function: load],
      transform: [Function: transform]
    },
    ...省略...
  ],

electron应用开发的入门

Electron是一个使用 JavaScriptHTMLCSS 构建桌面应用程序的框架。

ElectronChromiumNode.js嵌入到应用中,因此可以使用他们的特性,并天然的拥有跨平台的特性。

electron技术的核心概念

electron使用的是多进程架构,分为主进程渲染进程

主进程

主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。

Electron封装了很多原生API,这使得在主进程中有操控原生桌面功能的能力,例如菜单、对话框以及托盘图标。

渲染器进程

每个 Electron 应用都会为每个打开的 BrowserWindow ( 与每个网页嵌入 ) 生成一个单独的渲染器进程。 洽如其名,渲染器负责 渲染 网页内容。 所以实际上,运行于渲染器进程中的代码是须遵照网页标准的。因此渲染器进程中运行的代码,与web应用的开发方式是完全一致的。

渲染器进程可以完整的使用nodejs的api,但是出于安全考虑,这项特性现在已经被默认禁用。

electron应用快速入门

我们来编写一个electron应用的hello-world案例,了解如何开发electron应用。

electron是基于nodejs的,老生常谈的nodejs项目的初始化流程就此跳过了。


安装electron框架的依赖

npm install --save-dev electron

package.json中新增一条script命令,将main属性中指定为main.js

{
  "name": "electron-helloworld",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev":"electron ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^28.0.0"
  }
}

我们新增了一条dev命令,内容为electron .

electron应用启动的时候默认会取main属性中指定的js文件作为主进程的逻辑


编写main.js文件,其将在主进程中执行,具有完全的nodejs api的能力

// main.js

// Modules to control application life and create native browser window
const { app, BrowserWindow } = require('electron')

const createWindow = () => {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
    //   preload: path.join(__dirname, 'preload.js')
    }
  })

  // 加载 index.html
  mainWindow.loadFile('index.html')
}

// 这段程序将会在 Electron 结束初始化
// 和创建浏览器窗口的时候调用
// 部分 API 在 ready 事件触发后才能使用。
app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    // 在 macOS 系统内, 如果没有已开启的应用窗口
    // 点击托盘图标时通常会重新创建一个新窗口
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

// 除了 macOS 外,当所有窗口都被关闭的时候退出程序。 因此, 通常
// 对应用程序和它们的菜单栏来说应该时刻保持激活状态, 
// 直到用户使用 Cmd + Q 明确退出
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

我们来看一下main.js文件的内容:

引入appBrowserWindow模块,

  • app模块负责控制应用程序的事件生命周期
  • BrowserWindow模块,它创建和管理应用程序 窗口

添加一个createWindow()方法来将index.html加载进一个新的BrowserWindow实例

const createWindow = () => {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
    //   preload: path.join(__dirname, 'preload.js')
    }
  })

  // 加载 index.html
  mainWindow.loadFile('index.html')
}

Electron 中,只有在 app 模块的 ready 事件被激发后才能创建浏览器窗口。 您可以通过使用 app.whenReady() API来监听此事件。 在whenReady()成功后调用createWindow()

app.whenReady().then(() => {
  createWindow()
})

当所有的窗口都关闭,app则退出,electron相关进程都结束。


编写index.html,作为浏览器窗口渲染的内容

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>home</title>
</head>
<body>
    <h1>hello electron</h1>
</body>
</html>

最后,我们启动项目看一看效果

electron启动界面

vite整合electron开发的思路

在分析整合思路前我们先梳理下viteelectron各自的开发模式:

vite在开发阶段使用dev-server预览项目,其会提供一个url地址。部署阶段将项目打包成静态资源,包括htmljscss等等。

electron主进程中运行的代码是在Nodejs环境中,渲染进程运行的代码可以认为在浏览器环境中,浏览器窗口中加载的渲染内容可以选择是html静态资源也可以是一个url地址,如下:

//第一种 url方式
win.loadURL(VITE_DEV_SERVER_URL)
// 文件方式
win.loadFile(path.join(process.env.DIST, 'index.html'))

那么在分析vite整合electron进行开发的方案时,我们就可以设想以下的思路:

  • 在开发阶段,electron渲染进程通过访问vitedev-server暴露的url来加载内容
  • 在部署阶段,electron通过vite的构建产物加载内容

为了验证这种思路,我们分别基于viteelectron创建两个项目。

开发阶段:我们启动vite项目,vitedev-server提供的url是http://localhost:5173/,通过浏览器访问呈现的内容是:hello vite!!!

接着,我们进入electron项目,并将这个url写入electron的主进程代码中,

win.loadURL("http://localhost:5173/")

随后我们启动electron应用,效果如下:

直接成功了!electron中呈现的也是:hello vite!!!

部署阶段,过程也是类似,但是因为是两个项目过程较繁琐,我们跳过。

通过上述的实验,可以证明方案是可行的。但通过两个项目的方式开发electron应用终究是不优雅的,因此我们要探究一种能将这个思路完美整合到vite项目中的方式。

所幸vite的插件功能为我们提供了整合的可能性,接下来我们将探寻如何通过vite的插件,实现完美的基于viteelectron开发方案。

通过vite插件实现vite与electron的整合

我们通过开发一个vite插件的方案来实现vite方式开发electron应用,我们将分为两种场景分别应对,一个是开发阶段,一个是编译阶段。

开发阶段

首先是开发阶段,核心的思想就是让vite先通过dev-server的方式跑起来,并获取其url信息,再通过子进程的方式将electron应用启动,然后electron应用的渲染进程加载vitedev-server的内容。

落地到vite插件的实现上,我们可以通过configSever钩子,获取到vitedev-server的配置信息,并从中获取启动的url地址,然后保存到环境变量process.env中。我们通过监听dev-serverlistening事件,保证在vite完全启动后,再使用spawn执行electron . 命令,启动electron应用。

import { ChildProcess } from "child_process";
import { AddressInfo } from "net";
import { Plugin } from "vite";
interface MyElectronOption {

}
let electronApp: ChildProcess
export default (option: MyElectronOption): Plugin => {
    console.log('electron的vite-plugin开始执行...........')

    return {
        name: 'myElectronPlugin',
        configureServer: (server) => {
            // 监听server的listening事件
            let httpServer = server.httpServer!
            httpServer.once('listening', () => {
                let addressInfo = httpServer.address() as AddressInfo
                let url = `http://${addressInfo.address}:${addressInfo.port}`
                console.log(`vite启动服务的url信息是:${url}`)
                Object.assign(process.env, { VITE_DEV_SERVER_URL: url })
                startElectron()
            })
        }
    }
}
/**
 * 启动electron应用
 */
const startElectron = async () => {
    const { spawn } = await import('node:child_process')
    const electron = await import('electron')
    let electronPath = electron.default + ''
    console.log(`开始启动electron应用!启动命令为:${electronPath}`)
    electronApp = spawn(electronPath, ['.'], { stdio: 'inherit' })
    electronApp.once('exit', () => {
        process.exit()
    })
}

我们在App.vue中写了如下要呈现的内容

<script setup lang="ts">
// import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
<h1>hello vite+electron!!!</h1>
</template>

<style scoped>
</style>

来看看启动的效果

看到结果,惊喜万分!!我们已经初步实现了通过vite的方式开发electron应用!!!并且当我们改变前端内容时,也是支持热加载的。

但是,如果我们改变了electron主进程的内容则不支持热加载,并且如果我们希望通过ts的方式编写主进程代码也不支持的,那么我们接下来针对这些痛点进行优化。

优化插件

我们通过更改vite的配置并手动调用vitebuild方法即可将指定的文件进行预构建。

优化后的代码如下:

import { ChildProcess } from "child_process";
import { AddressInfo } from "net";
import { Plugin, InlineConfig, mergeConfig, build as viteBuild } from "vite";
import { builtinModules } from 'node:module'
interface MyElectronOption {
    vite: InlineConfig

}

let defaultViteConfig: InlineConfig = {
    configFile: false,
    publicDir: false,
    build: {
        lib: {
            entry: 'electron/main.ts',
            formats: ['cjs'],
            fileName: () => '[name].js'
        },
        outDir: 'dist-electron',
        emptyOutDir: false,
        watch: {},
        minify: false
    },
    plugins: []
}
let electronApp: ChildProcess
// 标识刷新主进程main.js的修改
let refreshFlag: boolean = false
// 标识第一次启动electron
let firstFlag:boolean = true
export default (option: MyElectronOption): Plugin => {
    console.log('electron的vite-plugin开始执行...........')

    return {
        name: 'myElectronPlugin',
        configureServer: (server) => {
            // 监听server的listening事件
            let httpServer = server.httpServer!
            httpServer.once('listening', () => {
                let addressInfo = httpServer.address() as AddressInfo
                let url = `http://${addressInfo.address}:${addressInfo.port}`
                console.log(`vite启动服务的url信息是:${url}`)
                Object.assign(process.env, { VITE_DEV_SERVER_URL: url })
                defaultViteConfig.mode = server.config.mode
                defaultViteConfig.plugins.push({
                    name: 'startElectron',
                    closeBundle: () => {
                        console.log('主进程代码重新构建完毕,开始启动electron应用......')
                        if(firstFlag){
                            firstFlag = false
                        }else{
                            refreshFlag = true
                        }
                        startElectron()
                    }
                })
                let viteConfig: InlineConfig = withExternalBuiltins(mergeConfig(defaultViteConfig, option.vite))
                viteBuild(viteConfig)
            })
        }
    }
}
/**
 * 启动electron应用
 */
const startElectron = async () => {
    if (electronApp) {
        electronApp.kill()
    }
    const { spawn } = await import('node:child_process')
    const electron = await import('electron')
    let electronPath = electron.default + ''
    console.log(`开始启动electron应用!启动命令为:${electronPath}`)
    electronApp = spawn(electronPath, ['.'], { stdio: 'inherit' })
    electronApp.once('exit', () => {
        if (!refreshFlag) {
            process.exit()
        }
        refreshFlag = false
    })
}

const withExternalBuiltins = (config: InlineConfig): InlineConfig => {
    const builtins = builtinModules.filter(e => !e.startsWith('_')); builtins.push('electron', ...builtins.map(m => `node:${m}`))

    config.build ??= {}
    config.build.rollupOptions ??= {}

    let external = config.build.rollupOptions.external
    if (
        Array.isArray(external) ||
        typeof external === 'string' ||
        external instanceof RegExp
    ) {
        external = builtins.concat(external as string[])
    } else if (typeof external === 'function') {
        const original = external
        external = function (source, importer, isResolved) {
            if (builtins.includes(source)) {
                return true
            }
            return original(source, importer, isResolved)
        }
    } else {
        external = builtins
    }
    config.build.rollupOptions.external = external

    return config
}

我们在electron/main.ts中编写electron主进程的逻辑,并通过vite构建到dist-electron/main.js下。因为electron识别的是js文件,我们还要将package.json中的main属性调整至:dist-electron/main.js

经过这个优化我们实现了以下特性:

  • 只要我们调整了electron/main.ts的内容,electron会自动重启并且不退出vite服务
  • 只要我们关闭electron应用,vite服务也会自动退出

小结

至此,我们通过vite插件的方式,已经基本实现了通过vite的方式开发electron应用这个目标,也就是我们完全可以按照原先开发web应用的方式来开发electron应用了。

关于构建场景下vite插件的实现,有空了再来详细记录填坑。

vite插件的实现方案参考的是vite-plugin-electron ,有兴趣的小伙伴可到他的仓库地址详细阅读源码。

关于本文中的所有代码,均已上传:github仓库:learnElectronAndVite

欢迎访问的我的个人博客:https://huanglusong.github.io/

欢迎加入我创建的qq技术交流群:624017389

引用

vite官方中文文档

Electron官方中文文档

vite-plugin-electron

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,首先,你需要确保已经安装了Node.js和npm。 接下来,你可以按照以下步骤使用electron-vite开发一个登录功能: 1. 创建一个新的Electron项目并进入项目目录 ``` mkdir my-electron-app cd my-electron-app ``` 2. 初始化npm ``` npm init -y ``` 3. 安装依赖 ``` npm install --save-dev electron electron-builder vite npm install --save express ``` 4. 创建一个vite.config.js文件 ``` const { defineConfig } = require('vite') module.exports = defineConfig({ build: { outDir: 'dist', emptyOutDir: true, target: 'esnext', // 设置 electron-main 为主进程入口文件 main: 'src/electron-main/index.js', // 设置 electron-renderer 为渲染进程入口文件 renderer: 'src/electron-renderer/index.js', }, }) ``` 5. 创建一个src/electron-main/index.js文件 ``` const { app, BrowserWindow } = require('electron') const path = require('path') let mainWindow function createWindow () { mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: true, }, }) mainWindow.loadFile(path.join(__dirname, '../electron-renderer/index.html')) mainWindow.on('closed', function () { mainWindow = null }) } app.on('ready', createWindow) app.on('window-all-closed', function () { if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', function () { if (mainWindow === null) { createWindow() } }) ``` 6. 创建一个src/electron-renderer/index.js文件 ``` const express = require('express') const app = express() app.use(express.static('public')) app.listen(3000, () => console.log('App listening on port 3000!')) ``` 7. 创建一个src/electron-renderer/index.html文件 ``` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>My Electron App</title> </head> <body> <h1>Welcome to My Electron App</h1> <script src="http://localhost:3000/login.js"></script> </body> </html> ``` 8. 创建一个public/login.js文件 ``` console.log('Login page') ``` 9. 运行应用 ``` npx electron-builder serve ``` 10. 打包应用 ``` npx electron-builder build ``` 以上就是使用electron-vite开发一个登录功能的步骤,你可以根据自己的需求进行修改和扩展。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值