个人快速落地全栈应用
这篇文章是个人的全栈开发经验,聚焦商业化、宣传、最佳实践这三个方向,总结了开发过程中踩过的坑和关键的点。
选择技术栈
使用一种语言,利用最丰富的生态和最广泛的平台
为什么选择 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-vite | Nw.js | Tauri | |
---|---|---|---|
优点 | 用的人最多,有 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)
binary | base64 | hex | |
---|---|---|---|
文件大小 | 最小 | 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 服务器实例
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 指定不打包的库名
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
<!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 源码,未来可期。
软件数据互通
通过文件 (简单)
通过文件导入导出是最简单的数据互通方式,开发工作量少,但一般需要软件官方的支持。
最大的问题是很难将自定义数据随文件导出。导出的限制一般较大,仅适合数据非常简单的场景。
常用设计建模软件数据互通
AutoCAD | Sketchup | Rhino | |
---|---|---|---|
文件解析 | 通过 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 可以在出错后自动重启