ts2php, 将你的 TypeScript 代码转换为 PHP

我们实现了一个工具 ts2php,可以将 TypeScript 文件转换为 PHP 文件,支持了大部分的 TypeScript 语法。它的意义在于可以让同一份逻辑,在前端(通过 JS)和后端(通过 PHP)都可以执行。

又一个装 X 项目?

为什么要将 TypeScript 转换为 PHP?当然因为 PHP 是世界上最好的语言😂。

玩笑放在一边,技术要为业务服务,这么做的原因,就是业务需要,对业务有帮助。

我们当前的项目有以下特点:

  1. 业务架构庞大、有较长历史。

    整个业务涉及了很多团队,且有很多历史代码,整体重构几乎不可能。

    但对于前端来说,组件化、引入前端开发框架提升开发效率,也是势在必行的。

  2. 后端使用 PHP 进行开发。

  3. 业务对性能要求严苛。

    由于对性能有很高要求,而直接引入一种开发框架以 SPA 形式进行开发,会有较长的白屏时间,因此后端渲染直出是必须的。

在这种情况下,我们当前的方案是一份代码编译为 JS 和 PHP 两份文件,分别运行在前端和后端。

这种场景下,难以避免有一些逻辑需要在前后端都执行。除了 DSL 转换为 PHP ,也需要将业务逻辑转换为 php 在后端执行。ts2php 尝试将 TS 转换为 PHP 文件,作为业务逻辑在后端执行的实践。

可能很多人会觉得直接重写成 Nodejs 多好?但这需要对大量代码进行重构,相信每个程序员都有重构代码的冲动,但重构并不一定是一个好的项目决策,它会阻塞整个项目的迭代速度,重复一遍之前解决过的bug。Joel 大神在他的博客中有这样一篇讲重构的文章,我觉得讲的很好:Things You Should Never Do, Part I[1]

很多时候渐进式的修改是一个更有效的办法。利用 ts2php ,我们可以渐进式的对现有架构进行升级。同时由于项目代码都可以使用 ts 书写,后续迁移 node 成本也会小很多。

另外,我们也通过实践证明了,虽然想法听上去有些不符合常规,但是,这确实是可行的。

为什么不是JS?

进行编程语言间的转换,一个难点在于抹平不同语言间运算语法的不一致。各语言间的运算是不能做好完全对应的,这就需要我们根据变量类型来将同一个源语言运算符转换为不同的目标语言运算符。

没有选择将 JS 转换为 php,是因为 JS 作为一个弱类型语言,编译阶段是无法判断变量类型的,这样在转换时,没办法对运算符进行取舍。举例来说,JS 中的 + 符号,当运算符两边是数字时,执行加法运算;两边是字符串时,执行字符串拼接。

1
2
console.log(123 + 456); // 579
console.log('123' + '456'): // '123456'

但在 PHP 中, + 符号只进行加法运算:

1
2
echo 123 + 456; // 579
echo '123' + '456'; // 579

我们需要判断 + 两边有字符串时,转换为 PHP 中的 . :

1
echo '123' . '456'; // '123456'

TypeScript

为了在编译阶段判断变量类型,我们需要给 JS 加上类型。目前 JS 社区中,有 Typescript 和 Flow 两种强类型方案,但选择 TypeScript 其实是没有难度的,TypeScript 的更新速度、生态,使用体验都相对更好,Vue 也将在 3.0 中使用 TypeScript 进行重写[2]

有越来越多的项目使用了 Typescript 进行开发,相信 TypeScript 就不用过多介绍了,对于复杂项目的维护和开发都是很有帮助的。

我之前的一篇文章:TypeScript 编译流程及内部类简介,希望能对大家了解 TypeScript 的编译及内部类能有帮助。

进度怎么样了呢?

ts2php 支持了大部分的 TS 语法,同时它是开源的。使用方式和支持了的的语法特性列表可以见 Github 项目首页:https://github.com/max-team/ts2php

当前 TS 转 PHP 方案,已经在我们的线上得到应用。我们的目标是实现一个 TS 的真子集,满足业务开发。

怎么做到的?

接下来介绍一下 ts2php 是如何实现的。

语言间的转换就是一个编译过程:将源语言转换为 AST,之后根据 AST 生成目标语言。

那么首先我们需要获取 TypeScript 的 AST。

TypeScript Compiler API

要进行语言间的转换,首先要做的就是得到源语言的 AST。JS 可以在 GitHub 上找到很多解析器,但 TypeScript 目前基本上只能依赖官方提供的 API。

目前 TypeScript 的 Compiler API[3] 还不是很稳定,但是已经可以提供强大的功能,比如:

  1. ts.createProgram

    createProgram 方法可以根据输入的多个文件,创建一个 Program。Program 指一个编译单元,它是由多个 SourceFile 和一些编译选项组成的。Program 是类型系统和代码生成的总入口。将多个文件合并在一起,是为了方便进行依赖分析。

    1
    let program = ts.createProgram(fileNames, options);
  2. SourceFile

    上面我们说 Program 是由多个 SourceFile 和一些编译选项组成的,那么 SourceFile 又是什么呢?SourceFile 本身是基础抽象语法树结构 Node 的一个扩展,他在抽象语法树的基础上增加了额外的接口,可以用来获取源文件代码、源文件地址、identifier 列表等功能。

    我们可以使用 program.getSourceFiles 来得到 Program 中的所有 SourceFile 并进行遍历。

    1
    2
    3
    for (const sourceFile of program.getSourceFiles()) {

    }
  3. TypeChecker

    我们选用 TypeScript 的一个主要原因就是类型系统,TypeScript 提供了 TypeChecker 来帮助我们在编译阶段判断一个变量的类型。我们可以从 Program 中得到一个对应的 TypeChecker:

    1
    let checker = program.getTypeChecker();

    TypeChecker 是 ts 类型系统的核心。它负责计算不同文件中 Symbols 的关系,判断 Symbol 的类型,生成语法诊断(也就是语法错误)。

    TypeChecker 可以回答以下问题:

    • 这个 Node 对应的 Symbol 是什么?
    • 这个 Symbol 的类型是什么?
    • 在这部分 AST 中,哪些 Symbol 是可见的?
    • 这个文件有哪些 error ?

    TypeChecker 实例的使用很简单,它提供了大量的方法,例如:

    1
    2
    3
    const symbol = typeChecker.getSymbolAtLocation(node.name);

    const nodeType = typeChecker.getTypeAtLocation(node);

转换流程

利用 TypeScript 的 Compiler API, ts2php 的执行路径可以抽象为一下步骤:

  1. 生成 Program。
  2. 得到 typeChecker。
  3. 获取并遍历 sourceFiles。
  4. 使用 transformer 对 sourceFile 的 AST 进行转换。
  5. 遍历 AST,使用 emitter 得到 PHP 代码。

感兴趣的同学欢迎看一下 ts2php 的代码,对 TypeScript 的编译方式,希望能有一定的帮助。

API 的对齐

对于 Core JavaScript API,很多方法在 PHP 无法找到直接的对应,例如 String.prototype.replace 等。

ts2php 新增了一个PHP 的类库,来解决这个问题。类库中实现了一些没有对应到的方法,使用时需要在 PHP 中引入:

1
require_once("/path/to/ts2php/dist/runtime/Ts2Php_Helper.php");

模块化

PHP 的模块机制跟 TypeScript 有着很大的不同,PHP 的 require 与 include 相当于将代码直接插入文件中执行,会有命名冲突问题。

例如有两个 PHP 文件 file1.php 和 file2.php,都定义了一个叫做 abc 的函数,如果 file2.php 引用了 file1.php,那么会报错:

1
2
3
4
<?php
function abc() {
echo 'file1';
}
1
2
3
4
5
6
<?php
require('./file1.php'); // Fatal error: Cannot redeclare abc()

function abc() {
echo 'file2';
}

因此 import 不能直接转换为 require,ts2php 使用 namespace 来进行 TypeScript 中 import 和 export 的转换。

export 转换为定义命名空间:

1
2
3
4
export default function run(a: string) {
var b = '111';
console.log(a, b);
}
1
2
3
4
5
6
<?php
namespace test\export;
function run($a) {
$b = "111";
echo $a, $b;
}

而 import 会转换为 use namespace 来使用命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {Other_Utils as Util} from '../some-utils';
import {Some_Utils, func} from '../some-utils';

type TplData = {
src?: string,
title?: string
};

const tplData: TplData = {};
tplData.src = Some_Utils.makeTcLink('url');
tplData.title = Some_Utils.highlight('title');
tplData.title = Util.sample;

tplData.title = func() + 'aa';
1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace test\import;
require_once(realpath(dirname(__FILE__) . '/' . "../some-utils.php"));
use \Other_Utils as Util;
use \Some_Utils;
use \func;

$tplData = array();
$tplData["src"] = Some_Utils::makeTcLink("url");
$tplData["title"] = Some_Utils::highlight("title");
$tplData["title"] = Util::$sample;
$tplData["title"] = func() . "aa";

注意事项

当然,TypeScript 转换成 PHP,一定是会有坑的,以下写法在语言转换以后,执行结果是不相同的,还未解决:

  • PHP 中 ‘0’ 和 [] 转换为 Boolean 类型时是 false,在做逻辑判断的时候需要特别注意。

  • PHP 中 || && 二元表达式返回结果只能是 true 或者 false,因此除纯逻辑判断的场景,其它情况需要使用三元表达式替代[4]。

    ts2php 计划尝试在编译时将 || 替换成 ?: 运算符

  • PHP 中不存在 undefined,尽量不要使用


以上就是我们做 ts2php 的原因、进度以及一些实现方式了,在这里求个 star 😝:https://github.com/max-team/ts2php

尽管是一个不太主流的方案,但我们相信,能解决业务问题的技术,就是好的技术。

相关文章

  1. Things You Should Never Do, Part I: https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
  2. Plans for the Next Iteration of Vue.js: https://medium.com/the-vue-point/plans-for-the-next-iteration-of-vue-js-777ffea6fabf
  3. Using the Compiler API: https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
  4. Quickest PHP equivalent of javascript var a = var1||var2||var3; expression: https://stackoverflow.com/questions/36450547/quickest-php-equivalent-of-javascript-var-a-var1var2var3-expression