我觉得有点无聊,所以就把我的网站用 Node 包做了个插件。方法如下。

2025-06-07

我觉得有点无聊,所以就把我的网站用 Node 包做了个插件。方法如下。

更新:我已经修改了我的网站,从终端设计开始,但你可以在这里找到该版本。

啊哈,明白了!你上当了,傻瓜。好了,我该停止胡闹了。文章标题说的没错,但有一些需要注意的地方。我的网站实际是这样的:

这篇文章会有点长,所以如果你不想读的话,这里有你需要的链接:

我为什么要这么做?

我讨厌建立网站。

好吧,这有点太夸张了。我写网络软件,但我不喜欢建网站。我更喜欢应用程序。我知道,语义化。

但说真的,我不喜欢担心这张图片应该放在哪里,横幅应该放在哪里,以及一堆链接应该放在哪里。

嘿,这并不意味着我不喜欢漂亮的网站。我喜欢——尤其是当它们是大量功能的关键时。

我更喜欢 Web 应用,那些不仅仅是展示信息的网站,还能接受你的输入并用它来做一些很酷的事情。我尤其喜欢开发它们的后端。

为了向您展示我有多不喜欢网站,以下是我的网站先前版本的样子:

好了,各位。就是这样。一些文字,几个图标。我想你会说它看起来不错,以一种极简主义的方式。我非常想在里面加入一些工程挑战,所以我加了一个 hack 来获取并显示我最近的一些 Medium 文章。

我一直在考虑重建我的网站,尤其是在我不断提升前端技能的时候。但是重建一个网站的想法让我感到无聊,所以我问自己:“我该如何把它做成某种应用程序呢?”

你说过度工程,我说 po-tah-to。

然后我想起几周前我曾尝试用 CSS 和 jQuery 构建一个命令行 UI。为什么不把我的网站变成一个终端呢?我见过几个类似的网站,它们真的很酷。从那时起,我开始思考,我一直想构建一个命令行应用,所以下一个想法就是“动手做!做一个也能在浏览器中运行的命令行应用!”太棒了!

所以我开始工作。

CLI 工具

首先,我知道我将向 CLI 和浏览器公开相同的功能,因此我从以下内容开始src/shalvah.js(为保持相关性而截断):

const shalvah = {
    bio: "Hi, I'm Shalvah. I'm a backend software engineer based in Lagos, Nigeria.",
    prompt: 'Want to know more about me? Check me out on these sites:',
    links: [
        {
            name: 'GitHub',
            value: 'https://github.com/shalvah',
        },
        {
            name: 'dev.to',
            value: 'https://dev.to/shalvah',
        },
        {
            name: 'Twitter',
            value: 'https://twitter.com/theshalvah',
        }
    ],
    email: 'hello@shalvah.me'
};

module.exports = shalvah;

Enter fullscreen mode Exit fullscreen mode

这个对象保存着我的所有信息。网站或 CLI 中的其他内容只是呈现和与它交互的一种方式。

然后我开始编写 CLI 界面。我使用了三个主要工具:

  • commander.js - 用于创建命令行应用程序
  • inquirer.js - 用于创建交互式 CLI 提示
  • opn - 用于从终端打开东西

在我的 package.json 中:

{
  "main": "src/shalvah.js",
  "bin": "cli.js"
}

Enter fullscreen mode Exit fullscreen mode
  • main:这是我之前创建的文件,所以每次运行require('shalvah'),最终都会得到这个对象。这样我就可以用浏览器 UI(或者我选择的任何其他 UI)来包裹它。
  • bin:Node 将链接为可执行文件的文件。因此,当您npm install -g shalvah,然后运行 ​​时shalvah,Node 将执行此文件。

以下是内容cli.js(再次为了相关性而截断):

#!/usr/bin/env node
const program = require('commander');
const inquirer = require('inquirer');
const opn = require('opn');
const shalvah = require('./src/shalvah');

program.description('Shalvah in your command-line')
    .parse(process.argv);

console.log(shalvah.bio);
inquirer.prompt({
    name: 'link',
    type: 'list',
    message: shalvah.prompt,
    choices: shalvah.links
}).then(answers => {
    console.log(`Opening ${answers.link}`);
    opn(answers.link);
});

Enter fullscreen mode Exit fullscreen mode

最终,非常简单。得益于这三个很棒的工具,只需几行代码就能创建一个功能齐全的 CLI 应用。

之后,剩下要做的就是将包发布到 NPM,然后使用 安装它npm install -g shalvah,瞧:

建立网站

这有点复杂。我原本打算安装我的 NPM 包,然后创建一个index.js作为浏览器入口的程序,也就是cli.jsCLI 的入口。它index.js会设置终端环境并向包发送调用。进展如何?

出色地...

创建终端 UI

我要做的第一件事就是处理终端 UI。我最近一直在提升我的前端技能,所以很想自己做。最终我决定使用库,因为我意识到我需要很多东西(比如事件处理程序和自定义按键处理程序),这些都需要我花时间编写、测试和重构。而且我对终端、缓冲区和 I/O 流的工作原理也了解不够。

我做了一些研究,发现最可行的选择是xterm.js。Xterm.js是一款功能非常强大的 Web 终端模拟器。可惜的是,它的文档亟待完善,所以我花了很长时间才弄清楚如何使用它。此外,它支持很多功能,但很多功能都是在底层实现的,所以我不得不围绕这些功能编写自定义处理程序。

将控制台移植到浏览器

接下来我想到的是,我非常喜欢控制台上显示的 Inquirer.js 提示符。我也想在网站上也用上它们。同样,我的选择是:自己写代码或者找个库。出于同样的原因,我再次选择了库。我决定在浏览器中使用我在 CLI 中使用过的库(Inquirer.js 和 Commander)。

我面临的一些挑战:

如何在浏览器中使用为命令行设计的包?

这时,Browserify就派上用场了。如果您不熟悉,Browserify 是一款很棒的工具,它允许您在浏览器中使用 Node 包。它还为 Node 提供了诸如 和process之类的“shim”(shim 就像一个伪造的) __dirname

插入指挥官

这相对容易,因为它的 API 要求你传入命令行参数(通常是process.argv)。在命令行中,运行shalvah help会填充process.argv类似于 的内容['/usr/bin/node', 'shalvah', 'help'],因此在浏览器中我执行了以下操作:

 commander.parse([''].concat(args));
Enter fullscreen mode Exit fullscreen mode

集成 Inquirer.js

这是一个更大的问题。它的代码主要读写process.stdout/ process.stdin,而 / 是 的实例Readline.Interface。好消息是:

  • 该库依赖于行为(某些方法的存在),而不是继承(x instanceof Readline.Interface),并且
  • Xterm.js 已经支持 readline 的大部分功能。我编写了一个 shim 函数,假装它xterm.js是 readline 接口的一个实例,然后使用Browserify 的别名转换功能,将 Inquirer 期望的 shim 替换成readline我自己的 shim 函数。这个 shim 函数的简化版本如下所示:
module.exports = {
  createInterface({ input, output }) {
      // so we don't redefine these properties
      if (input.readlineified) {
          return input;
      }
      // normally, input and output should be the same xterm.Terminal instance
      input.input = input;
      input.output = input;
      input.pause = function () {};
      input.resume = function () {};
      input.close = function () {};
      input.setPrompt = function () {};
      input.removeListener = input.off.bind(input);

      Object.defineProperty(input, 'line', {
          get: function () {
              return input.textarea.value;
          }
      });
      input.readlineified = true;
      return input;
  }
};

Enter fullscreen mode Exit fullscreen mode

Xterm.js 已经有一个write函数,因此不需要定义它。

我还必须做一些非常具体的垫片:


    // The most important shim. Used by both Commander and Inquirer.
    // We're tricking them into thinking xterm is a TTY 
    // (see https://nodejs.org/api/tty.html)
    term.isTTY = true;

    // Xterm is both our input and output
    process.stdout = process.stdin = process.stderr = term;

    // Shim process.exit so calling it actually halts execution. Used in Commander
    process.exit = () => {
        term.emit('line-processed');
        throw 'process.exit';
    };
    // catch the process.exit so no error is reported
    window.onerror = (n, o, p, e, error) => {
        if (error === 'process.exit') {
            console.log(error);
            return true;
        }
    };

    // For inquirer.js to exit when Ctrl-C is pressed (SIGINT)
    process.kill = () => {
        process.running = false;
        term.writeln('');
        term.writeThenPrompt('');
    };
Enter fullscreen mode Exit fullscreen mode

适当调整终端尺寸

我面临的另一个挑战是调整终端的大小,使其在桌面端和移动设备上都能很好地显示,并且不出现难看的滚动条。以下是我希望它在移动设备上的效果:

实现起来有点困难,因为终端窗口的大小不仅受 CSS 规则的影响,还受每行行数和列数的影响,而行数和列数又受字体大小的影响。这非常棘手。即使列数多出一个单位,也会出现滚动条。在研究和尝试了多种方法之后,我最终决定采用以下方案:


    const term = new Terminal({
        cursorBlink: true,
        convertEol: true,
        fontFamily: "monospace",
        fontSize: '14',
        rows: calculateNumberOfTerminalRows(),
        cols: calculateNumberOfTerminalCols(),
    });

    // This measures the height of a single character using a div's height
    // and uses that to figure out how many rows can fit in about 80% of the screen
    function calculateNumberOfTerminalRows() {
        let testElement = document.createElement('div');
        testElement.innerText = 'h';
        testElement.style.visibility = 'hidden';
        document.querySelector('.term-container').append(testElement);
        testElement.style.fontSize = '14px';
        let fontHeight = testElement.clientHeight + 1;
        testElement.remove();
        return Math.floor(screen.availHeight * 0.8 / fontHeight) - 2;
    }

    // This measures the width of a single character using canvas
    // and uses that to figure out how many columns can fit in about 60% (80% for mobile) of the screen
    function calculateNumberOfTerminalCols() {
        const ctx = document.createElement("canvas").getContext('2d');
        ctx.font = '14px monospace';
        const fontWidth = ctx.measureText('h').width + 1;
        const screenWidth = screen.availWidth;
        return Math.floor(screenWidth * ((screenWidth > 600) ? 0.6 : 0.8) / fontWidth) + 3;
    }
Enter fullscreen mode Exit fullscreen mode

这可能看起来有点过度设计,但这是我能想到的最可靠的方法。

颜色支持

我希望我的终端能显示颜色(谁不想呢?),我用的程序是chalk。可惜的是,chalk 似乎无法与 Xterm.js 兼容,所以经过几个小时的调试,我发现问题出在用于检测颜色的外部依赖项chalk 上,于是我把它替换成了我的 shim 程序:

module.exports = {
    stdout: {
        level: 2,
        hasBasic: true,
        has256: true,
        has16m: false
    }
};

Enter fullscreen mode Exit fullscreen mode

移动设备上的切换提示

你会注意到,在我之前的例子中,我使用了一个名为 的 Inquirer 提示符list,它允许你使用箭头键来选择选项。然而,在移动设备上,通常没有箭头键。所以我不得不切换到rawlist移动设备上的提示符(使用数字输入):


    inquirer.prompt({
        name: 'link',
        type: (screen.width > 600) ? 'list' : 'rawlist'
});
Enter fullscreen mode Exit fullscreen mode

经过漫长的时间,终端终于可以运行了!

值得吗?

总的来说,这对我来说是一段紧张却有趣的经历,我学到了很多新东西。这是我第一次接触,甚至可以说是第一次学习我在这里描述的大部分内容,所以这真是个胜利。我甚至最终能够使用一些基本的 Unix 实用程序,比如cdls(试试看!😁😁)

仍然有一些 bug,尤其是在移动设备上,但我实在不想等到一切都完美,所以就直接发布了。希望你们喜欢!

文章来源:https://dev.to/shalvah/i-was-bored-so-i-made-my-website-into-a-node-package-heres-how-2id3
PREV
JavaScript 中的事件循环是什么?
NEXT
我如何在一天内构建大约 60% 的应用程序代码库。