19.04.20228 min

Pascal ThormeierFullstack Web Developer

CSS na backendzie - wprowadzenie do kaskadowych arkuszy serwerów

Sprawdź, jak wykorzystać CSS jako język serwerowy do deklarowania routingu, wykonywania obliczeń i szablonowania.

CSS na backendzie - wprowadzenie do kaskadowych arkuszy serwerów

I znowu to robimy! Jeszcze jeden taki, a obiecuję, że po tym zwątpicie w mój zdrowy rozsądek. Akurat robiłem zakupy. Szedłem sobie ulicą do sklepu i wtedy to do mnie dotarło. Kaskadowe... arkusze serwerów!

Dzisiaj wykorzystamy CSS jako język serwerowy. Tak, zgadza się! Wykorzystaj CSS do deklarowania routingu, wykonywania obliczeń, a nawet do szablonowania! I nie używamy niczego takiego jak SASS czy LESS (pff, nie potrzebujemy żadnych śmierdzących pętli!), lecz starego dobrego CSS.


Co?? Dlaczego??

Wyobraź sobie, że zmieniasz oponę za pomocą teleskopu Hubble’a. Nie do końca wychodzi, prawda? Wyobraź sobie jednak to wspaniałe uczucie, kiedy udaje ci się to zrobić. I właśnie o to mi chodzi. Kurcze, może zapoczątkowuję tutaj nowy trend, kto wie! Nawet jeśli ten trend będzie się tylko śmiał z moich głupich pomysłów i nigdy więcej nie potraktuje mnie poważnie.

Być może znasz powiedzenie, że “ludzie tak się przejmowali, czy mogą, że zapomnieli zapytać, czy powinni”. Doskonale zdaję sobie sprawę z tego, że prawdopodobnie nie powinienem, ale pytanie brzmi: czy mógłbym?

To narzędzie będzie czymś, czego nigdy, przenigdy nie użyję w produkcji, a Ty, drogi czytelniku, również nie powinieneś tego robić. Bardzo proszę. Jak coś... to ostrzegałem.


No dobra, kaskadowe arkusze

st... arkusze serwerów oczywiście

Po pierwsze, określmy, jak to wszystko ma w ogóle działać. Zastanawiałem się nad interfejsem do programu Express. Zasadniczo należy zdefiniować trasę catch-all w Expressie, wczytać plik CSS, przetworzyć i zinterpretować style (ta część może być ciekawa) i wysłać dowolny DOM, który się pojawi.

Aby to zrobić, musimy najpierw zainstalować program Express. Zwróć uwagę, że używam nvm do przełączania się między wersjami Node.

echo "14" > .nvmrc
nvm use
npm init # Hit enter a few times
npm i express


Fajnie! Teraz stwórzmy małą aplikację i dodajmy skrypt startowy do pliku package.json:

{
  "name": "css-server",
  "version": "1.0.0",
  "description": "A bad idea.",
  "main": "index.js",
  "scripts": {
    "start": "node ./css-server.js"
  },
  "author": "Pascal Thormeier",
  "license": "donttrythisathome",
  "dependencies": {
    "express": "^4.17.2"
  }
}


W aplikacji express definiujemy trasę catch-all, która próbuje ustalić, czy dana trasa odpowiada plikowi CSS, czy nie. Jeśli plik istnieje, zwraca po prostu jego zawartość, jeśli nie, zostanie wyświetlony komunikat 404.

const express = require('express')
const bodyParser = require('body-parser')
const path = require('path')
const fs = require('fs')

const app = express()

// Allows to get POST bodies as JSON 
app.use(bodyParser.urlencoded({ extended: true }))

// Catch-all route
app.use((req, res) => {
  let cssFile = req.path

  // So `index.css` works.
  if (cssFile.endsWith('/')) {
    cssFile += 'index'
  }

  const cssFilePath = path.resolve('./app' + cssFile + '.css')

  try {
    const css = fs.readFileSync(cssFilePath, 'utf8')
    res.send(css)
  } catch (e) {
    // Any error of the file system will 
    // be caught and treated as "not found"
    res.sendStatus(404)
  }
})

app.listen(3000)


Szybki test pokazuje, że wszystko, z wyjątkiem małego pliku  index.css, daje wynik 404; plik CSS jest wyświetlany.


Ocena CSS — głośno myśląc

No dobrze, to teraz część rozrywkowa. Musimy jakoś wymyślić, jak zrealizować CSS po stronie serwera i potraktować to, co zostanie wyświetlone, jako odpowiedź aplikacji.

Pierwszą rzeczą, która przychodzi na myśl w przypadku renderowania, jest po prostu użycie reguły CSS content do renderowania. A czego? No właśnie — zawartości. Może używać zmiennych CSS i liczników, więc technicznie możemy nawet wykonywać za jego pomocą obliczenia matematyczne.

Jest tylko jeden problem. Przeglądarka ocenia liczniki i zmienne na bieżąco, więc nie możemy po prostu poddać ocenie CSS, wziąć tego, co jest w content i wyciągnąć z tego output. Zatem podejście w stylu “obliczeniowym” się tutaj nie sprawdza (uwierzcie mi, próbowałem...).

Zasadniczo otrzymasz to, co widzisz w zakładce "CSS" w swoich dev tools.

Wyobraź sobie taki fragment CSS:

body {
  --num1: 12;
  --num2: 13;
  counter-set: sum 15;
}

body::before {
  content: '<h1>The sum is ' counter(sum) '</h1>';
}


Otrzymasz coś takiego:

Hm. Czemu więc nie wykorzystać do tego celu przeglądarki? Przeglądarka w jakiś sposób ocenia te rzeczy, prawda? Jedynym problemem jest to, że przesunęliśmy problem w czasie. Istnieją implementacje Node w CSS. Oferują one obliczane style, a przeglądarka, z której korzystamy, oferuje tylko to samo, prawda? Gdyby tylko istniał sposób na to, aby komputer “czytał” to, co jest na ekranie.

Idealnie byłoby, gdyby przeglądarka wczytała plik CSS, a my nie wstawialibyśmy niczego do tekstu; w przeciwnym razie nie możemy używać takich rzeczy jak np. @import. Potrzebujemy więc kolejnego sterownika, który będzie ładował pliki CSS.

Brzmi to bardzo jak mój problem “z przyszłości”. Wprowadźmy najpierw lalkarza i sprawmy, by wykonywał CSS.


Dodajmy puppeteer

Po prostu:

npm i -s puppeteer


Aby załadować CSS, potrzebujemy trochę HTML-a. Możemy to utworzyć na szybko, wstrzyknąć wczytany CSS jako <link>, zakodować cały blob w base64 i sprawić, że przeglądarka go przetworzy:

const escapeVarValue = value => {
  if (!isNaN(value)){
    return value
  }

  return `'${value}'`
}

const createDOM = (cssFilePath, method, args) => {
  const varifiedArgs = Object.entries(args).map(([key, value]) => `--${key}: ${escapeVarValue(value)};\n`).join("\n")
  const dataifiedArgs = Object.entries(args).map(([key, value]) => `data-${key}="${value}"`).join(' ')

  return `
    <!DOCTYPE html>
    <html data-http-method="${method.toUpperCase()}">
      <head>
        <style>
          :root {
            ${varifiedArgs}
          }
        </style>
        <!-- Load the actual CSS -->
        <link rel="stylesheet" href="${cssFilePath}">
      </head>
      <body ${dataifiedArgs}>
      </body>
    </html>
  `
}


Zwróć uwagę, że metoda HTTP została już dodana jako atrybut danych, a argumenty jako zmienne CSS i atrybuty danych.

Następnie dodajemy trasę _internal do naszej aplikacji express, która obsługuje żądany plik CSS:

app.get('/_internal/*', (req, res) => {
  const appPath = req.path.replace('_internal', 'app')
  if (appPath.includes('..') || !appPath.endsWith('.css')) {
    res.send('Invalid file')
    return
  }

  const internalFilePath = path.resolve('.' + appPath)
  res.sendFile(internalFilePath)
})


Żądanie wysłane do  /_internal/index.css spowoduje załadowanie pliku app/index.css i jego dostarczenie. Lalkarz może teraz załadować kod naszej aplikacji i go wykonać. Moglibyśmy dokonać większej walidacji, ale ze względu na przejrzystość ograniczyłem się do podstaw.

Teraz musimy wprowadzić do gry lalkarza:

const getContent = async (cssPath, method, args) => {
  const dom = createDOM(cssPath, method, args)

  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  })
  const page = await browser.newPage()
  const base64Html = Buffer.from(dom).toString('base64')

  await page.goto('data:text\/html;base64;charset=UTF-8,' + base64Html, {
    waitUntil: 'load',
    timeout: 300000,
    waitFor: 30000,
  })

  // Magic!
}


Wypróbujmy to na podstawowym, małym index.css:

body::after {
  content: '<h1>Hello, World!</h1>';
}


No i proszę: Działa! Nasz lalkarz wykonuje CSS i wyświetla wynik:

Interesujące działanie niepożądane: Zmiana headless: true na false pozwala nam na debugowanie CSS. Gotowy debugger to z pewnością fajna rzecz.


Wyodrębnianie zawartości

Pamiętasz jeszcze mój problem “z przyszłości”? No dobrze, wiemy, że nie możemy używać stylów obliczeniowych, aby uzyskać dowolny element z content, zwłaszcza jeśli zawiera on zmienne lub liczniki. Nie możemy również zaznaczać i kopiować/wklejać wyrenderowanego tekstu, ponieważ Chromium nie potrafi tego robić. Jak więc uzyskać wyrenderowany, przetworzony tekst?

Czy zdarzyło Ci się kiedyś pobrać stronę internetową w formacie PDF? Przetwarzany tekst będzie można zaznaczyć. Czy lalkarz może utworzyć plik PDF z witryny internetowej? Tak, może. Czy można w jakiś sposób przetworzyć plik PDF, aby uzyskać tekst? Oczywiście, że możemy!

npm i -s pdf-parse

Biblioteka ta umożliwia przetworzenie dowolnego pliku PDF i wyodrębnienie z niego tekstu. Nie robimy tu żadnych dziwactw z obrazami, układami i innymi tego typu rzeczami. Wyświetlamy tylko zwykły HTML jako nieprzetworzony ciąg znaków.  Możemy to skopiować/wkleić:

const pdf = require('pdf-parse')

const getContent = async (cssPath, method, args) => {
  const dom = createDOM(cssPath, method, args)

  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  })
  const page = await browser.newPage()
  const base64Html = Buffer.from(dom).toString('base64')

  await page.goto('data:text\/html;base64;charset=UTF-8,' + base64Html,{
    waitUntil: 'load',
    timeout: 300000,
    waitFor: 30000,
  })

  // Get a PDF buffer
  const pdfBuffer = await page.pdf()

  // Parse the PDF
  const renderedData = await pdf(pdfBuffer)

  // Get the PDFs text
  return Promise.resolve(renderedData.text)
}


Na koniec dostosujmy trasę catch-all, aby otrzymać tekst:

// Catch-all route
app.use((req, res) => {
  let cssFile = req.path

  // So `index.css` works.
  if (cssFile.endsWith('/')) {
    cssFile += 'index'
  }

  cssFile += '.css'

  // File doesn't exist, so we break here
  if (!fs.existsSync(path.resolve('./app/' + cssFile))) {
    res.sendStatus(404)
    return
  }

  const cssFilePath = 'http://localhost:3000/_internal' + cssFile

  getContent(cssFilePath, req.method, {
    ...req.query, // GET parameters
    ...req.body, // POST body
  }).then(content => {
    res.send(content)
  })
})


To powinno załatwić sprawę.


Demo time!

No więc teraz to przetestujmy.


Kalkulator za pomocą formularza

Podstawowe “Hello World” jest zbyt proste. Zbudujmy kalkulator CSS:

body {
    --title: '<h1>Calculator:</h1>';
    --form: '<form method="POST" action="/"><div><label for="num1">Number 1</label><input id="num1" name="num1"></div><div><label for="num2">Number 2</label><input id="num2" name="num2"></div><button type="submit">Add two numbers</button></form>';
}

[data-http-method="POST"] body {
    counter-set: sum var(--num1, 0) val1 var(--num1, 0) val2 var(--num2, 0);
}

[data-http-method="GET"] body::before {
    content: var(--title) var(--form);
}

[data-http-method="POST"] body::before {
    --form: '<form method="POST" action="/"><div><label for="num1">Number 1</label><input id="num1" name="num1" value="' counter(val1) '"></div><div><label for="num2">Number 2</label><input id="num2" name="num2" value="' counter(val2) '"></div><button type="submit">Add two numbers</button></form>';
    counter-increment: sum var(--num2, 0);
    content: var(--title) var(--form) '<div>Result: ' counter(sum) '</div>';
}


Ten kalkulator wykorzystuje wiele funkcji:

  • Reagowanie na metody GET i POST
  • Wykonywanie obliczeń matematycznych
  • Wyświetlanie wyniku

A co to właściwie robi?

Wyrenderujemy tytuł oraz formularz z dwoma polami wejściowymi o nazwach num1 i num2. Jeśli “aplikacja” napotka żądanie POST, wyświetla wynik, który jest obliczany za pomocą licznika CSS. Licznik CSS jest najpierw ustawiany na num1, a następnie zwiększany o liczbę num2, co daje sumę tych dwóch liczb. Stąd też: Podstawowy kalkulator dodawania.

Działa? Działa:

Prosta, dwustronicowa aplikacja z nawigacją

Wyodrębnijmy trochę nagłówka i trochę stopki do pliku globals.css:

:root {
    --navigation: '<ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li></ul>';
    --footer: '<footer>&copy; 2022</footer>';
}


Możemy go następnie wykorzystać w pliku index.css tak jak poniżej:

@import "./globals.css";

body::after {
    content: var(--navigation) '<h1>Hello, World!</h1>' var(--footer);
}


Działa jak magia:

Pff. Ale jazda.


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>