要把前端和后端写在同一个文件里

假如我们需要设计一个需要密码才能登录的页面,某些偷懒的大聪明可以很容易地写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
function login(password) {
if (password === 'fuck') {
return 'hello';
} else {
return 'wrong password';
}
}

document.getElementById('login-button').addEventListener('click', async () => {
const password = document.getElementById('password').value;
alert(await login(password));
});

如果读者有一定的Web开发经验,一定能一眼看出这段代码很好地解释了什么叫掩耳盗铃。

当然,如果你还看不出,那么我们稍微提示一下

前端代码在浏览器中运行,用户可以很容易地拿到前端代码。

所以只需要稍微分析一下,就能直接获得正确的密码“fuck“,这种在前端进行验证的行为显然只有大聪明才能写出来。

那么,如果我们把验证部分放到后端进行呢?

好,你成功领悟到了前后端分离的核心思想,于是我们吭哧吭哧地搭了一个后台服务器,再吭哧吭哧地写API文档,最后吭哧吭哧地联调上线,最后在某个通宵的夜里,当所有功能正常通过测试,我们终于可以坐下来歇一口气:#&¥*@&#&¥*&%(@&#$$*#%@……

那么,我为什么不能直接在前端写后端的代码呢?

于是你得到了Remix

……

哈哈,开个玩笑,Remix虽然部分实现了这么个操作,但我认为依然不够灵活,因此,我尝试了设计一个编译器/框架/库库(总之随便你怎么叫吧,我就叫编译器了),让开发者可以同时在一个文件里写前和后端代码,并在编译时分离出前端和后端代码,进行分别部署。

那么,它怎么用呢?

啊,实际上我还没写完,你问的太快了。

当然,在我写完之前,我觉得有必要整理一下当前的需求,并设计相应的语法出来。

1. 在注释中添加 @remux <名字> 标记该代码运行在哪个端上

也可以是别的什么关键字,总之目前先用remux吧。

我们以上面的代码为例,进行如下修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// @remux server
function login(password) {
if (password === 'fuck') {
return 'hello';
} else {
return 'wrong password';
}
}

// @remux client
document.getElementById('login-button').addEventListener('click', async () => {
const password = document.getElementById('password').value;
alert(await login(password));
});

好,现在编译器就知道了,**login函数在服务器上运行,下面那条语句只会在客户端上运行,在服务端上会被删除**。

于是我们发现了新的问题,客户端的函数怎么可能直接调用服务器上的函数呢?!所以接下来,我们需要使用RPC(Remote Process Call,远程过程调用)对非本端的函数进行封装。

2. 非本端的函数会封装成RPC

好吧,就算函数没问题了,但是变量怎么办呢?例如下面的代码

1
2
3
4
5
// @remux server
let aServerVar = 114514;

// @remux client
aServerVar += 1;

我们看到,经过编译后,aServerVar这个变量的声明在客户端上会消失,这就导致接下来aServerVar += 1;中的变量未声明而执行出错,我们不希望这种情况发生,因此我们给编译器设下第3条规则

3. 非本端的变量声明会被封装为Proxy并实现RPC

RPC真是太棒了,可是编译器并不关心开发者使用什么RPC方案,所以我们决定把RPC的具体实现留给开发者,编译器本身只负责封装

4. 与标记名关联的函数会被作为RPC客户端调用

具体来说呢,在客户端上,上面的login函数会被封装成

1
2
3
4
5
6
7
8
async function login(...args) {
return await clientInvoke(
'function', // this is a function RPC
'server', // the remote is server
'login', // function name is login
args // the arguments
});
}

而对于变量声明,则会封装成

1
2
3
4
5
6
7
8
9
10
11
12
13
let aServerVar = new Proxy(
['server', 'aServerVar'],
new Proxy(() => {}, {
async apply(target, func, args) {
return await clientInvoke({
'proxy', // this is a proxy RPC
'server', // the remote is server
func, // the proxy handler name
args // handler arguments
})
}
}
);

而对于被调用的端,存在编译器提供的函数用于接收调用

1
2
3
4
5
6
7
8
9
10
// 这是编译器提供的!不需要开发者自己写
async function serverHandler(mode, func, args) {
if (mode === 'function') {
switch (func) {
...
}
} else {
...
}
}

是不是很简单?所以开发者只需要实现一下xxxInvoke函数,并写好相应的RPC服务(甚至可以直接在这个文件里写好),把参数一股脑传给yyyHandler,然后返回到xxxInvoke就完成啦,RPC从未如此简单。

也许对于一些没写过RPC的小白,这可能有一定困难,因此我们会尝试提供一个专为该编译器设计的RPC实现,以方便需要快速上手的玩家们。

当然,这也就意味着在server端上,clientInvokeclientHandler是完全没必要的,所以

5. 非本端的RPC函数会被删除

我们还希望所有端都能共用一些代码,例如某些类型的声明、公共的函数、公共的变量等,所以

6. 未标记的代码所有端上都会执行

但是有些代码我们压根就不希望被其他端调用,例如私有的函数或是变量,尽管可以在RPC层面做限制(RPC必须有限制,不然会产生任意执行漏洞),但是我们还是可以让编译器做一些处理,在此,我想出了两种方案

  • 使用块语句,例如

    1
    2
    3
    4
    5
    // @remux server
    {
    function aPrivateFunction () {}
    let aPrivateVar = 1919810;
    }

    这种方式的好处是不会破坏JavaScript语法,坏处是可能无法实现某些功能,例如在公共函数中调用私有函数或变量(当然反过来没问题,在私有语句中调用公共函数或变量)

  • 使用额外的标记,例如// @remux server private声明私有的变量和函数,这样在其他端上会被抹去,而不是封装成RPC,但是这种做法会对语法造成一定的破坏,这是我不太希望看到的,尽管编译器可以对不正确的变量或函数使用进行警告或报错,但依然会对开发者在编写程序上造成一定的困惑

对于这个需求的实现方法我还在犹豫不决,因此,如果你有好的想法欢迎在下方留言处指点,或者直接与我交流,总之,在此保留第7条规则。