Skip to content

个人快速落地全栈应用

这篇文章是个人的全栈开发经验,聚焦商业化、宣传、最佳实践这三个方向,总结了开发过程中踩过的坑和关键的点。

选择技术栈

使用一种语言,利用最丰富的生态和最广泛的平台

为什么选择 ts ?

  • ts 的应用范围广

    ts = js,在服务器、网页、移动端、pc 端都可以使用,是应用最广泛的编程语言。 Node.js 从 23 版本(2025 年)开始支持直接运行 ts 脚本,现在前端、后端、服务器端都可以引用同一个 ts 的类型声明文件,降低了全栈开发的复杂性(例如联网授权功能)。

  • ts 开发体验好

    ts 为 js 语言提供 “类型检查 + 注释提示”,是提高大型项目开发效率的关键。ts 支持 /** */ 形式的 jsdoc 注释,可以在其他任何被引用的地方提示(例如模块被另一个文件导入),不至于自己写的代码一个月后就看不懂了。ts 可以帮助写出更好的代码。例如启用 strict 模式,会将一些低效率的 js 语法列为错误提示。

为什么大项目使用 js 效率很低 ?

  • 需要处理繁杂的版本差异。以模块化开发为例,js 就有 CJS、AMD、CMD、UMD、ESM 5 种规范。ts 将处理 js 版本差异的工作交给编译器,对于新手来说无须深入学习这些规范就能上手开发。ts 可以使用相同的语法开发前后端,最终会按配置重新编译成不同规范的 js 文件。
  • 没有原生的类型检查,只能在运行时调试,有额外的配置工作(设断点或写 console.log)。相较于写 ts 类型,小项目可以节约时间的,但项目大了则会浪费很多时间。
  • jsdoc 可以实现部分类型检查的功能,但会增加工作量。因为 jsdoc 本质上是一段自定义的注释,跟源码没有直接联系。实际开发时,变量和函数是在频繁改动的,为了正确检查类型 jsdoc 也得同步修改。而 ts 的类型本身是源码的一部分,很多提示信息会随着源码自动更新。

为什么选择 Vue ?

原生支持 ts。

上手快,一周即可。模块化,适合项目拆分。

响应式,简化了 UI 开发。生态好,可选的框架很多。

推荐 ts + vue

选择应用框架

对比流行的 ts + vue 应该框架
Electron-viteNw.jsTauri
优点用的人最多,有 Electron-vite 工具链,只用 JS 就能开发前后端,对旧系统支持很好(整体打包了 Node.js)功能与 Electron 基本相同。原生支持加密 JS 源码。打包文件较小 (100 mb)Rust 上限很高。新版本支持移动端。打包文件很小 (10 mb)
缺点打包文件较大 (200 mb)用的人不多Rust 难学。后端开发效率低(语法检查和编译都很慢)

这些应用框架的核心功能都类似。快速落地推荐 Electron-vite。长远发展推荐 Tauri

源码保护方案

如果不打算走通过开源获取流量变现的方式,保护源码是商业化的前提。

通过打包工具保护前端(弱)

打包工具都支持 treeshake、mangle,可以将自己的代码与引用的库打包在一起,并混淆函数和变量名称。这些方式适合保护授权和核心算法以外的代码。虽然打包后的代码是开源的,但几乎无法阅读,要还原很费时费力,总的来说保护效果较弱。

推荐使用 vite

前端开发常用的打包工具 webpack、esbuild、rollup、vite ,配置都很麻烦(webpack > rollup > esbuild)。vite 是 vue 生态圈中的官方打包工具,基于 esbuild 和 rollup 包装的,开箱即用。

通过 ssl 加密保护前端(较弱)

一些 ssl 算法数学上被验证是几乎无法破解的(如 AES-256),但都需要密钥用于加密和解密。如何保护密钥和解密流程是保护前端源码的关键。

前端加密绕不开在本地进行解密的环节(如果在服务端解密,返回的数据可以被中间人拦截)。因此通过应用框架在后端保护密钥和解密函数是刚需,Tauri 是首选。

但该方案难以保证源码不泄露。解密的源码始终需要通过语言的解释器运行,而解释器都不会考虑闭源运行的问题。除非重构解释器,这个过程总是暴露的。

选择加密数据的编码类型 (encoding)
binarybase64hex
文件大小最小binary 的 1.33 倍binary 的 2 倍
编码方式单一有多种字母表单一
前后端通信不可用可用可用

binary 数据无法在这些应用框架的前后端传输。传输都是以字符串编码的形式,因此只能使用 base64 和 hex。

base64 和 hex 本质上是再次编码加密的 binary 数据为字符串。base64 一般来说是首选,因为体积相对较小。但如果是跨语言使用的情况(例如从 Node.js 服务器发送加密数据到 Tauri 的后端解密),因 base64 有多种编码方式处理起来比较复杂(Rust 需要引入库),这时就可以考虑使用更简单的 hex 格式。

首选 base64

通过应用框架保护后端(强)
  • Nw.js

是通过 Node.js 将 JS 代码编译成字节码(bytecode)。这种保护效果一般:有开源工具可以反编译。反编译代码与源码相似,且可以被修改再使用。 字节码无法加密字符串。例如加密用的密钥如果是字符串,直接打开文件就能看到(可以通过 String.charCodeAt() 转 UTF-16 码元数组规避)。

  • Electron

会将源文件整体打包成 asar 文件,该方式不是加密,有开源工具可以直接还原。也可以自己通过 Node.js 编译字节码实现与 Nw.js 相同的保护方式,但较为麻烦。

  • Tauri

直接将后端的 Rust 代码编译成二进制数据。Rust 以安全性著称,代码的反编译难度高于 C/C++,也高于字节码,保护效果很强。但需会 Rust,上手成本高。

保护强度 Tauri > Electron/Nw.js

其他加密方案(难以实践)
  • 自定义解释器

    以 Ruby 语言为例,商业的加密工具 RubyEncoder 是通过 C 语言按照 Ruby 的解释器实现了具有一套不同规则的自定义解释器,再辅助一些自定义编码规则,整个流程都被编译成 dll 文件。加载加密的代码是在 Ruby 环境中运行 dll 文件,通过内部的解释器来解密文件。实现这套方案需要大量的开发调试工作,难度很大。优点则在于可以应用于任何语言,还能封装独一无二的加密逻辑,非常难被破解。

  • 代码混淆

    有一些 js 代码混淆工具,技术上主要包括混淆函数和变量名称,重构控制流,插入无用的代码等等。工具本身相对独立,需要写一些脚本才能纳入前端工具链。部分功能与前端打包工具相似,但内部规则是不透明的,插入无用的代码可能会产生一些负面影响,最终代码的运行效率不可控,实测混淆代码有概率无法运行。总的来说,不如直接用前端打包工具。

大原则是将需要保护的数据和源码放在后端,通过应用框架编译文件进行保护

如数据或源码必须放在前端,可在后端保护密钥和解密函数,后端解密再发送到前端

实现商业授权

应用前端

视为开源的,仅提供界面让用户输入信息,发送 POST 请求到服务器再转发结果到后端。

前端打包工具仅仅增加了反编译难度,无法做到安全加密。若通过 ssl 加密前端代码再通过后端解密再在前端 eval,流程会很复杂,开发调试的难度会增加。因此前端最好被视为开源的,代码尽量简单,授权的全部流程都在服务器和后端。前端也应被视为可修改的,那么被授权使用的功能也应放到后端,否则可以通过修改前端直接调用。

服务器

最安全,接收并验证请求并保存用户数据,再返回加密的数据结果。

  • 较简单的方式,是使用与应用后端相同的对称加密算法。密钥是相同的,但分别被服务器和应用后端保护,中间流程不会暴露。但应用端密钥万一被破解,服务器就形同虚设。
  • 更安全的方式,是使用非对称加密,服务器保存的私钥用于加密,应用后端保存的公钥用于解密。即使应用保存的公钥被反编译出来也无法用于生成新的加密数据。

如采用订阅制,与时间相关的计算须放到服务端,不然可以通过修改本地时间获取授权。实际使用时会频繁与服务器通信,流量成本较大。需长远考虑,一旦服务器下线,应用也就无法使用了。

Node.js 服务器实例
js
import http from 'node:http'
import mysql from 'mysql2/promise'
import { Buffer } from 'node:buffer'
import { createCipheriv, createDecipheriv } from 'node:crypto'

const key = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
const iv = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

/** 将utf8字符串加密为 hex 数据 */
function encrypt(string) {
  const bKey = Buffer.from(key)
  const bIV = Buffer.from(iv)
  const encoding = 'hex'

  const cipher = createCipheriv('aes-128-gcm', bKey, bIV, { authTagLength: 16 })
  const data = cipher.update(string, 'utf8', encoding) + cipher.final(encoding)
  return data + cipher.getAuthTag().toString(encoding)
}

const maxDevices = 3
const port = 9876
const pool = mysql.createPool({
  host: 'localhost',
  database: 'myDB',
  user: 'admin',
  password: '12345678',
})
const uuidSep = ','

// 处理请求,返回结果为 JSON 字符串
async function getResponseJSON(req) {
  /** 返回的数据 */
  const response = { message: '注册码或邮箱错误', type: 'error' }
  const { id, email, key, uuid } = req
  if (id && email && key && uuid) {
    /** 须通过 email 和 RegKey 的双重验证,以避免RegKey碰撞 */
    const where = `WHERE Email='${email}' AND RegKey='${key}'`
    const result = await pool.query(`SELECT * FROM ${id} ${where}`)
    const target = result[0][0]

    if (target) {
      // 空字符串分割可能产生 ['']
      const uuidSaved = target.uuid.split(uuidSep).filter((x) => x)
      if (target.uuid.includes(uuid)) {
        response.message = `已注册 (${uuidSaved.length}/${maxDevices})`
        response.type = 'success'
        response.data = [new Date().toLocaleString(), uuid]
      } else {
        if (uuidSaved.length < maxDevices) {
          uuidSaved.push(uuid)
          await pool.query(`UPDATE ${id} SET uuid='${uuidSaved.join(uuidSep)}' ${where}`)
          response.message = `新增注册 (${uuidSaved.length}/${maxDevices})`
          response.type = 'success'

          response.data = [new Date().toLocaleString(), uuid]
        } else {
          response.message = `注册已满 (${maxDevices}/${maxDevices})`
          response.type = 'warning'
        }
      }
    }
  }

  // 直接返回加密信息,在应用后端解密
  if (response.data) response.data = encrypt(id, JSON.stringify(response.data))
  return JSON.stringify(response)
}

// 定义服务器
const server = http.createServer(async (req, res) => {
  if (req.method === 'POST') {
    try {
      // 处理 CORS
      res.setHeader('Access-Control-Allow-Origin', '*')

      let body = ''
      req.on('data', (chunk) => (body += chunk.toString()))
      req.on('end', async () => {
        try {
          const reqObject = JSON.parse(body)
          const resJSON = await getResponseJSON(reqObject)
          res.writeHead(200)
          res.end(resJSON)
        } catch (err) {
          res.writeHead(500)
          res.end(`Internal server error:${err}`)
        }
      })
    } catch (err) {
      res.writeHead(500)
      res.end(`Internal server error:${err}`)
    }
  }
})

// 服务器,启动!
server.listen(port, () => console.log(`Start RegServer:${port}`))
应用后端

较为安全,服务其返回的加密数据转发到后端进行解密、验证、保存,同时负责保存和调用被授权的核心功能。

前端与授权相关的操作都须转发到后端再操作。

付费制和订阅制的前后端功能都差不多,区别订阅制的服务器开发量更大。

小项目推荐付费制,大项目推荐订阅制

话说回来,产品功能才应该是优先考虑的内容。薄利可以多销,被破解了对产品口碑有利

如可能从付费制转为订阅制,最好事先声明

快速宣传要点

利用源码发布 Demo

一个常见场景,是将开发过程中的部分功能直接发布到网站的二级域名,作为 Demo 宣传或 Web 服务试用。

应用框架的源码如直接作为网页打包发布,会因为文件较大下载和打开速度很慢。一种解决方案是在打包时分块,但下载的流量依然较大,这在按流量计费租赁服务器时不太理想。另一种方案就是通过 importmap 将引用的库改为从其他服务器下载(国内推荐 bootcdn,国外推荐 jsdelivr),不会占用本地服务器的流量。

应用框架都有自己的发布流程。建议另建文件夹,通过独立的 esbuild 脚本打包并发布 Demo。

实测会因 CDN 导致打开速度不稳定,建议仅限开发时使用,发布时取消 importmap

importmap 的配置案例

vite.config.js 通过 build.rollupOptions.external 指定不打包的库名

js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'

export default defineConfig({
  base: '/bsc',
  plugins: [vue(), vuetify()],
  build: {
    outDir: './dist/bsc',
    rollupOptions: {
      external: [/three.*/],
    },
  },
})

index.html 通过 importmap 指定 CDN

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="data:image/ico;base64,aWNv" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Demo</title>
    <script type="importmap">
      {
        "imports": {
          "three": "https://cdn.jsdelivr.net/npm/three@0.172.0/build/three.module.min.js",
          "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.172.0/examples/jsm/"
        }
      }
    </script>
  </head>

  <body>
    <div id="app"></div>
    <script type="module" src="./src/main"></script>
  </body>
</html>
通过 vitepress 发布说明文档

vitepress 是 vue 生态圈中的一个官方静态站点构建工具。

不同于 vuepress,VitePress 的目标是拥有编写文档所需的最低限度功能,开箱即用。比较新,开发体验还不够好,但胜在简单、高效,可以直接引用 ts + vue 源码,未来可期。

软件数据互通

通过文件 (简单)

通过文件导入导出是最简单的数据互通方式,开发工作量少,但一般需要软件官方的支持。

最大的问题是很难将自定义数据随文件导出。导出的限制一般较大,仅适合数据非常简单的场景。

常用设计建模软件数据互通
AutoCADSketchupRhino
文件解析通过 npm 库 dxf 解析导出的 dxf 文件导出 Collada 文件通过 Three.js 解析官方支持在 Three.js 直接读取 Rhino 文件
自定义数据 只能通过图层名称导出只能通过组件名称导出导出方便,详见文档
通过本地 Web 服务器(复杂)

在应用的后端建立一个 http 服务器,从需要数据互通的软件里加载自定义脚本,向服务器端口发送 POST/GET 请求,然后根据返回的数据回调。

还有很多更复杂的 Web 服务器,可以阅读这篇 好文章 。从个人经验来看,80% 你认为需要这些高级服务器的场景,都可以用最简单的 http 服务器代替。

首选通过本地的 http 服务器

云服务器常见问题

按流量还是按带宽租赁服务器 ?

个人用户首选按流量计费,流量月费一般大大低于带宽月租(我目前平均每天 10 次左右的网页浏览加服务器授权请求,每月不超过 1 元)。等月费用超过月租时再修改也不迟。

是否需要注册和备案域名 ?

不一定需要。租服务器就得到一个公网 ip,不需要域名也可以访问。

请求端口超时 ?

云服务大都采用 “云服务器 + 宝塔面板” 的模式,端口管理各自独立,需要同时配置。

另外端口的使用有较多乱七八糟的限制。例如宝塔面板的默认端口是 8888,即使在宝塔设置面板改成了其他的,该端口依然会被占用。

配置好的端口报错,就换一个!

请求返回跨域(CORS)报错 ?

服务器返回消息的 header 需设置 Access-Control-Allow-Origin:*

还一种特殊情况是仅发送请求而不需要返回消息。服务器基本都支持这种无须跨域的模式,只需要设置发送请求的 mode:no-cors

Node.js 服务器如何维护 ?

在服务器安装 npm 库 pm2,包含了维持进程、自动重启、数据统计等常用功能,很方便。

Node.js 服务器最需要注意的是异常处理,未处理的异常会导致服务器下线。

不过 pm2 可以在出错后自动重启