likes
comments
collection
share

ReScript 试玩 - 前端开发的新选择

作者站长头像
站长
· 阅读数 30

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

去年的八月份,我意外的接触到了ReScript。初次体验后,给我的感觉就是,这是一个很好的语言。并不是说它天下无敌,而是单纯的在跟目前主力使用的各类语言里,它的各类特性都让我感觉良好。

Rust是一门这两年一直很有争议的语言,我也常常尝试“入门”这个堪称折磨人的语言。初次尝试入门,仅仅是因为我喜欢Rust的语法,但是Rust特色的Borrow机制,以及怎么用都很奇怪的生命周期,让我每次都止步不前。

而ReScript,虽然它是一门ML家族的语言,但是在语法上,都有很多跟Rust相似的地方,包括默认不可变量,结构体,函数式。估计有部分人跟我一样,也是喜欢Rust语言,但是有希望Rust能有GC,这样就可以更加畅快的使用。

ReScript虽然并不完全符合带GC的Rust的设想,但是依然是目前可选的一个替代品。

当然,我最终选择学习,并尝试使用的原因,并不是拿它当Rust的替代品(首先目前而言是做不到),而是因为它也是一门可以编译到JavaScript的语言(目前官方也只支持JS),而作为一个合格的中级切图工程师,一些跟JS相关的技能点,都要时不时的增加或者更新一些,所以学习ReScript个人认为还是很必要的,因为指不定哪一天它火了,就跟当初TypeScript一样。

不过要注意,本文内容将会只对如何让ReScript快速在项目中实际使用进行讨论,基础语法,譬如类型,变量系统之类的基础部分不进行深入讨论。所以本文将会重点讨论如何通过官方提供的装饰器来应用ffi,然后像普通JS一样使用,

配个环境

官方文档里有提供了一套完整的安装教程,但是其实目前而言,是一套更加独立的解决方案,跟目前前端技术领域的热门方案没有合理的进行结合,这估计也是造成了ReScript一直不是很热门的原因。所以我们这次配置直接考虑结合Vite使用,并且直接用ReScript写一个操作DOM的小案例。

创建一个空的Vite项目

为了减少配置过程,这里直接使用Vite提供的官方模板,创建一个 vanilla 模板。

> pnpm create vite

然后进入对应的项目目录,安装基础依赖

> pnpm i

安装ReScript相关依赖

这里需要用到两个东西,一个是ReScript编译器本身,但是ReScript编译器只提供了ReScript编译工具链,而我们因为要使用Vite,所以还必须找到对应的插件。

很幸运,就算是再小众的语言工具,只要还有点热度,npm上也确实都存在。

安装@jihchi/vite-plugin-rescript,当然也不要忘记了ReScript编译器本体,然后在vite.config.js里完成配置,就可以了。

> pnpm add @jihchi/vite-plugin-rescript rescript -D

安装完成后,根据 @jihchi/vite-plugin-rescript 插件文档的提示,我们现在有两种方式可以在html文件内引入我们的脚本文件,一种是直接引入对应输出的js文件,但是我们今天就是要配置成第二种,那就是直接引入res文件,不为别的,就是因为这样才感觉我们真的再用ReScript在写代码(虽然最终也是被重定向到对应的js文件)

然而有问题

这个功能文档内有提示,只支持inline的方式引入,也就是必须是JavaScript内部的模块系统,而不能像TypeScript一样,直接写在script标签的src上

<!-- index.html --><!-- 这样是不行的 -->
<script type="module" src="./src/Main.res"></script><!-- 这样才可以 -->
<script type="module">
import "./src/Main.res;
</script>

但是其实这里有好几个坑,我不清楚是不是Windows系统特有的坑,文档中实际没有提到,那就是你必须在插件的配置上写一个loader,而loader里必须配置js文件输出的路径,而这个路径必须是绝对路径,否则将会出现文件路径错误的问题。

// vite.config.jsimport { defineConfig } from "vite"
import createReScriptPlugin from '@jihchi/vite-plugin-rescript';
import { resolve } from "node:path"export default defineConfig({
  plugins: [createReScriptPlugin({
    loader: {
      output: resolve(__dirname)
    }
  })],
})

上面看似解决了问题,其实这时候第二个问题出现了,那就是热更新不生效了。

这个问题一开始出现我以为是我配置的问题,但是实际上调试下来,发现是插件导出的一个配置产生的问题。

这里插件讲res文件的监听ignore掉了,但是其实我更不能理解的是,这个时候如果你直接修改对应的.bs.js也是不生效的,目前还未可知是插件的问题,还是vite的问题。

目前已经尝试联系作者反馈这个bug,此bug仅在Windows电脑上会出现,macOS不会,Linux没有尝试

ReScript 试玩 - 前端开发的新选择

只能这样写

老老实实把代码改掉吧。(后面测试在部分Windows电脑上面这样写其实也会出问题,表现为rescript的vite插件不会触发编译器的更新)

<!-- index.html --><script type="module">
import "./src/Main.bs.js;
</script><!-- 或者这样 -->
  <script type="module" src="/src/Main.bs.js"> </script>

上面的配置仅仅只是配置了插件跟Vite启动的选项,如果我们这个时候真的创建了一个res文件,然后执行了pnpm dev那依然会得到一个错误提示,那就是缺少bsconfig.json文件,这是ReScript编译器本身会用到的一个配置文件。

ReScript 试玩 - 前端开发的新选择

这个文件就跟TypeScript的tsconfig.json类似,是编译器用来真正读取的配置,用来控制各种编译选项以及打包输出选项的,所以为了能够正常执行,我们必须创建一个。根据现在的需求,在项目根目录创建,并写入如下内容即可。

// bsconfig.json
{
  "name": "项目的名字",
  "sources": [
    {
      "dir": "src", // update this to wherever you're putting ReScript files
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "es6",
      "in-source": true
    }
  ],
  "suffix": ".bs.js",
  "bs-dependencies": []
}

在保证目录是如下完整的情况下,执行pnpm dev,我们初步的项目配置就完成了。

ReScript 试玩 - 前端开发的新选择

运行完就会显示端口号,正常打开,是没有任何东西的。

ReScript 试玩 - 前端开发的新选择

跑个Hello World

既然项目运行没问题了,那按照惯例肯定是写一个HelloWorld了,这里不再提及ReScript编写的细节,基础语法部分可以直接去看官方文档:Introduction | ReScript Language Manual (rescript-lang.org)

因为目前大部分markdown都还不支持ReScript的高亮,所以这里借助了Rust的语法高亮

// src/Main.res
let hello  = (target)=>Js.log(j`Hello $target`)
​
hello("World")

如果没有特别大的情况,这里其实都会在浏览器的Console里输出Hello World

ReScript 试玩 - 前端开发的新选择

浏览器里的Hello World

上面使用到的输出函数Js.log是ReScript提供在标准库内的一个函数,直接映射了ES标准下的console.log,ReScript提供的标准库其实真的就只包含了针对ES标准下的部分,所以浏览器提供给JS的各种函数其实在ReScript里并没有官方提供,虽然现在已经有很多社区库已经帮我们封装了,但是这里我们就是要学习一下,如何通过ReScript提供的各类装饰器来创建在浏览器里会用到的对象跟函数。

创建一个alert函数

@val external alert: (string)=>unit = "alert"

上面的代码很简单,一共四个部分组成。

  • @val 装饰器,代表从目标语言(一般是JS)的全局获取值或对象
  • external 外部引用关键字
  • alert:(string)=>unit 声明变量跟类型
  • = "alert" 代表真实映射的值,相当于globalThis['alert']

创建完的函数直接调用,就可以在浏览器上看到弹窗了。

// src/Main.res@val external alert: (string)=>unit = "alert"alert("Hello World")

ReScript 试玩 - 前端开发的新选择

认识装饰器

在个人实践过程中,实际会用到固定几个装饰器,而这几个装饰器基本上能完成大部分的功能,它们就是:

  • @val
  • @scope
  • @module
  • @send
  • @get/@set
  • @get_index/@set_index
  • @new
  • @this

如果能够完全掌握这些装饰器,就可以尝试为ReScript生态移植各类JS库,这样也可以大大降低自己对ReScript的使用难度。

当然,官方提供的装饰器还有很多,但是有些装饰器的功能其实是可以用上面提到的装饰器实现出类似效果,这个要看具体情况而定,官方装饰器查询:Syntax Lookup (rescript-lang.org)

在构建alert时我们就用到了一个@val,这个装饰器是用来创建一个从JS里映射到ReScript编译器可以认识的"对象值"的,实际上就相当于在编译器上开了一个后门,我们仅仅只是告诉编译器可能存在的东西,但是编译器不再为我们保证这些值是否真实存在,所以在使用这些装饰器的时候,要确认自己是否使用正确。

不过,单纯的从文档或者官方的解释文案中,对于装饰器本身的认识可能还是很模糊的,而本文意在能将ReScript实际应用到开发中,所以接下来将会尝试通过几个实践案例来直接认识装饰器的用法。

操作DOM

同样的,操作DOM部分的api在ReScript也没有直接提供,但是ReScript并没有完全让我们重头封装DOM的意思,它帮助我们预制了很多DOM相关的类型,所以借助这些类型,再加上装饰器的辅助,其实就能实现出很多功能。

其实这里又要强调一点,ReScript是函数式的编程语言,而且本质上它就是为了编译到其他平台(或者说语言平台)而创造的,所以它的类型系统设计出来就自带了很多帮助你去除平台差异性的功能,所以在移植JS或者浏览器提供的api时,更多的是在编写类型,并且配合类型导出各类操作函数,这些函数可能是external的,也可以是自己通过ReScript代码二次封装的。

其实官方文档也在很多地方默默的给出了封装DOM操作的思路,比如在@send的介绍案例里,就有一段这样的代码

// Bind to a method on an object
type document
@send external getElementById: (document, string) => Dom.element = "getElementById"
@val external doc: document = "document"
let el = getElementById(doc, "myId")

这里可以看到官方创建了一个空类型document来辅助实现后续的功能,这里其实就是为了展示ReScript本身类型是平台无关的特点,官方虽然提供了Dom.document这个类型的标准库,但是实际上这个类型本身并不具备任何意义,而只在你实际封装过程赋予它功能,这种情况可能跟TypeScript的类型系统最不一样的地方。

这段代码内还用到了@send这个装饰器,这个其实就是因为ReScript本身不提供显示的OOP,但是JS是一门OOP语言,而JS又有非常灵活的对象,可谓是万物皆对象,所以在JS里,任何一个值或者变量上可能都有很多无法预测的属性或者方法,虽然这里提到了属性,但是在JS里,一个属性也可能是函数类型的变量,在调上上跟方法并没有区别。

但是这在ReScript是没办法完美表达出来的,并且如果每一个对象的映射都想做到完全一致的还原,其实是一件很麻烦的事情,所以在ReScript里,对于一个对象的映射可以的零散的,也就是你可以只映射其中某些属性或者方法。但是这种零散的映射又通过@send来帮忙在编译回JS时,来帮忙重新关联到相关的对象,这些步骤其实通过直接查看编译出来的JS文件就可以简单的验证

// Generated by ReScript, PLEASE EDIT WITH CARE
​
​
var el = document.getElementById("myId");
​
export {
  el ,
  
}
/* el Not a pure module */

不过为了更加简单的让初学者理解,我尝试将转换的过程翻译成JS的伪代码

// 这部分是模拟编译过程中的操作过程
// @send external getElementById: (document, string) => Dom.element = "getElementById"
const _0 = "getElementById"
// @val external doc: document = "document"
const document = globalThis["document"]
// let el = getElementById(doc, "myId")
const el = document[_0]("myId")

这里的_0只是模拟一个创建映射名的中间变量,而在编译过程中,其实一般都还会做一个静态分析的过程,将中间变量直接优化成常量值。

不过上面仅仅是作为证明@send功能的一个模拟猜想,并不能作为真正编译器实现来参考(毕竟我也没有真的看过编译器的代码)。

封装常见的DOM方法

上面仅仅是针对官方文档的理解,但是实际上这些已经足够让我们做很多事情了,比如我们已经可以尝试将常用的querySelector,createElement,appendChild这三个api做一个基础的封装。

为了简化封装过程,封装过程中的中间类型也全部采用了自定义的空类型。

type document
type element
​
@val external document:document = "document"
@val external body:element = "document.body"
@send external query_selector:(document,string)=>option<element>="querySelector"
@send external create_element:(document,string)=>element="createElement"
@send external append_child:(element,element)=>unit="appendChild"switch document->query_selector("#app") {
  |Some(app)=>{
    let h1 = document->create_element("h1")
    app->append_child(h1)
    let h2 = document->create_element("h2")
    body->append_child(h2)
  }
  |_=>()
}
​

在浏览器查看运行结果,发现成功运行

ReScript 试玩 - 前端开发的新选择

@get/@set封装属性

上面的操作中,我们成功封装了方法,但是这种方式创建的元素是空,按照一般思路,这个时候其实是可以通过innerHTML来对元素进行内容的插入。但是innerHTML在JS里是一个属性,而是是字符串类型的属性,而在使用@send封装时,我们会发现,@send封装出来的代码在最终编译回JS里时,都是作为函数调用的,这就很明显无法实现修改属性的需求了。

不过ReScript肯定是考虑到了这类场景,因此@get/@set就可以派上用场了

@get external get_inner_html:(element)=>string = "innerHTML"
@set external set_inner_html:(element,string)=>unit = "innerHTML"

这里可以看到,实际上在映射的过程中,属性也被映射成了函数的形式,这是因为ReScript在编译时@get/@set时,会根据映射进来的函数签名来判断挂载的对象的。

修改代码,给h1跟h2分别添加内容

let h1 = document->create_element("h1")
h1->set_inner_html("Hello World")
app->append_child(h1)
let h2 = document->create_element("h2")
h2->set_inner_html("Hello World")
body->append_child(h2)
Js.log(h1->get_inner_html)

然后查看浏览器,发现效果是正确的

ReScript 试玩 - 前端开发的新选择

ReScript 试玩 - 前端开发的新选择

查看编译后的代码,发现编译的代码确实就是直接调用属性进行修改

// Generated by ReScript, PLEASE EDIT WITH CARE
​
import * as Caml_option from "rescript/lib/es6/caml_option.js";
​
var app = document.querySelector("#app");
​
if (app !== undefined) {
  var h1 = document.createElement("h1");
  h1.innerHTML = "Hello World";
  Caml_option.valFromOption(app).appendChild(h1);
  var h2 = document.createElement("h2");
  h2.innerHTML = "Hello World";
  document.body.appendChild(h2);
  console.log(h1.innerHTML);
}
​
export {
  
}
/* app Not a pure module */
​

未完待续

不知不觉也写了很多文字,不过写文章字写多属实有点累,所以这部分先写到这里暂停,后续会考虑继续讲解其他的几个在文中提到的装饰器。

总结

其实在使用ReScript时,应该尽可能的不要用传统的OOP语言的思路来思考,但是实际上大家在日常工作中接触OOP的时间更长,所以ReScript其实也很贴心的提供了帮忙转换思路的装饰器来辅助封装。

引用