Saltearse al contenido

Creando un Navegador: Parte II diagramas y código

a lo weno xD

Que vamos a hacer ?

  1. Crear la forma de escribir nuestras UIs
  2. Buscar Archivo UI
  3. Interpretar Archivo
  4. Guardar Archivo
  5. Dibujar Archivo

Flujo

(event) Cuando el usuario seleccione un archivo desde su maquina:

  1. Leemos el archivo
  2. Parseamos el archivo
  3. Actualizamos el state
  4. Dibujamos en pantalla

Folder structure

  • index.html
  • index.js para iniciar nuestra implementación
  • modules/file-loader.js para buscar archivos
  • modules/file-parser.js para interpretar archivos
  • modules/state.js para guardar el estado
  • modules/renderer.js para dibujar nuestro state en pixeles

folders


Empezemos


1. Crear la forma de escribir nuestras UIs

🤔🤔🤔

Habia pensando en YAML, pero no resultó ser lo más conveniente(según yo).

  • Que tan intuitivo queremos que sea la declaracion de nuestra interfaz ?
  • Cual es el mental model que queremos reflejar ?
  • Que tan fácil es leer/interpretar/parsear este archivo?

mental model: como se comprende el funcionamiento de algo.

  • Cuando crees que un audio de wssp es una llamada
  • Cuando le escribes al Viejo Pascuero, esperas y llegan las cosas.
  • Si rezaste y algo bueno te pasó, debido a la eficiencia de tu rezo.

no hay falla en su logica, if u know wht i mean


  • como queremos que se entienda? 🤔
  • Queremos que se entienda el funcionamiento interno o no?
  • Le ponemos Viejito Pascuero o no ? 🤔

Un ejemplo de Viejito Pascuero en codigo es: class en Javascript.

Javascript no soporta Clases, pero te muestra un mental model familiar.

Esto tambien se llama: Sintactic Sugar.

" En informática, el azúcar sintáctico es un término acuñado por Peter J. Landin en 1964 para referirse a los añadidos a la sintaxis de un lenguaje de programación diseñados para hacer algunas construcciones más fáciles de leer o expresar. Esto hace el lenguaje "más dulce" para el uso por programadores: las cosas pueden ser expresadas de una manera más clara, más concisas, o de un modo alternativo que se prefiera, sin afectar a la funcionalidad del programa." wikipedia

Ejemplos:

Svelte

Te permite trabajar los aspectos relevantes de tu funcionalidad en 1 solo archivo.

Describiendo cada aspecto en Tags ( como en Plain HTML).

<script>
import Nested from './Nested.svelte';
</script>
<p>These styles...</p>
<Nested />
<style>
p {
color: purple;
font-family: 'Comic Sans MS', cursive;
font-size: 2em;
}
</style>

Astro

Cuando escribes en Astro tienes una sección para JS y otra para el template separados por: ---

---
// Component Script (JavaScript)
import Button from './Button.astro';
---
<!-- Component Template (HTML + JS Expressions) -->
<div>
<Button title="Button 1" />
<Button title="Button 2" />
<Button title="Button 3" />
</div>

Angular

Puedes hacer las 2 cosas.

  • por defecto: puedes tener archivos por separado
  • o escribirlo todo en un archivo

No importa la forma que escogas, el build será el mismo.

Angular mental model

Remix

Es un framework fullStack, te permiten definir el frontend y backend en el mismo archivo.

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { db } from "~/utils/db.server";
// se ejecuta en el Backend
export const loader = async () => {
return json({
sandwiches: await db.sandwich.findMany(),
});
};
// se ejecuta en el Frontend
export default function Sandwiches() {
const data = useLoaderData<typeof loader>();
return (
<ul>
{data.sandwiches.map((sandwich) => (
<li key={sandwich.id}>{sandwich.name}</li>
))}
</ul>
);
}

Qwik

Tambien FullStack y tiene un approach similar.

import { component$, Resource } from "@builder.io/qwik";
import { RequestHandler, useEndpoint } from "@builder.io/qwik-city";
import { Contact, CONTACTS } from "./db";
// se ejecuta en el Backend
export const onGet:RequestHandler<Contact[]> = async () =>{
return await Promise.resolve(CONTACTS);
}
// se ejecuta en el Frontend
export default component$( () =>{
const endpoint = useEndpoint<type of onGet>();
return (
<div>
<h3>Contacts</h3>
<Resource
value={endpoint}
onPending = {()=> <div>Loading...</div>}
/>
</div>
)
})

And so on…


Volvamos

Para nuestra versión,

  • Vamos a buscar algo sencillo de entender y transformar: JSON.
  • Vamos a priorizar la facilidad del flujo sobre la experiencia del Dev.
  • Vamos a permitir configurar 3 aspectos de la interfaz:

screen: para configurar los elementos y aspectos visuales de la UI

env: para configurar nuestro brauser

metadata: agregar informacion adicional

UI Schema

default-ui.json UI

Crear la forma de escribir nuestras UIs ✅


Sigamos

Fujo

  1. Cuando el usuario seleccione un archivo desde su maquina:
  2. Leemos el archivo
  3. Parseamos el archivo
  4. Actualizamos el state
  5. Dibujamos en pantalla

0.Cuando el usuario seleccione un archivo desde su maquina:

  1. Vamos a escuchar el evento change del input file
  2. Cuando ocurra, vamos a a ejecutar los pasos 1,2,3,4.

Nota: el evento no trae el archivo, trae la referencia del archivo.

index.js
const initBrowser = (event) => {
// get file
// parse file
// update state
// render
};
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', initBrowser);
});

1.Leemos el archivo

  • Evento, dame la referencia al archivo.
  • file-loader.js, traeme el contenido de esta referencia.
  • si pudiste leer el archivo => devuelve el contenido.
  • si no pudiste => throw new Error.
index.js
import { fileLoader } from './modules/file-loader';
const initBrowser = async (event) => {
try {
// get file
const fileReference = event.target.files[0];
const uiTxt = await fileLoader.getFileAsTxt(fileReference);
//--
// parse file
// update state
// render
} catch (error) {
console.warn({ message: 'Unable to read file', error });
}
};
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', initBrowser);
});
file-loader.js
export const fileLoader = {
getFileAsTxt: (reference) => {
return new Promise((resolve, reject) => {
const reader = new FileReader(); // FileReader viene del Navegador
// scenarios
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
// read
reader.readAsText(reference);
});
},
};



2. Parseamos el archivo

  • Vamos a crear un modelo en memoria basado en el contenido del archivo.
  • file-parser: quiero que conviertas este txt en un objeto valido de UI.
index.js
import { fileLoader } from './modules/file-loader';
import { fileParser } from './modules/file-parser';
const initBrowser = async (event) => {
try {
// get file
const fileReference = event.target.files[0];
const uiTxt = await fileLoader.getFileAsTxt(fileReference);
// parse file
const uiModel = await fileParser.toUI({
from: 'txt',
file: uiTxt,
});
// -
// update state
// render
} catch (error) {
console.warn({ message: 'Unable to read file', error });
}
};
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', initBrowser);
});

file-parser.js

  • toUIHandler: contiene las diferentes formas de transformar nuestra UI
  • En esta version vamos a implementar txt => UI
file-parser.js
// formas de transformar en UI , desde TXT, XML, etc
const toUIHandler = {
txt: (txt) => {
// nuestra implementacion irá aca...
},
xml: (xml) => {
throw new Error('Not implemented', { data: xml });
},
error: ({ from, file }) => {
throw new Error('Invalid transformation', { data: { from, file } });
},
};
export const fileParser = {
toUI: ({ from, file }) => {
// switch abreviado
return toUIHandler[from](file) || toUIHandler.error({ from, file });
},
};

Como hacer el Parseo?

Con un pipeline de ejecucion.

Cuando procesamos archivos estos por lo general se dividen en 3 tipos de transformaciones:

  • pre-processing: validar la data y preparla para el procesamiento
  • processing: se procesa la data
  • post-processing: se limpia la data.

🤔 y porque no todo en un map?

Ordenarlo de esta forma, nos sirve para reforzar el mental-model.

Esta idea viene del Batch processing o procesamiento en lote de archivos.

Puro server masticando datos.

Este approach te da mas control:

  • Al tener cada Step modularizado puedes medir la eficiencia, consumo, costos de cada uno.
  • easy to isolate errors
  • easy to compose

Para el frontend no es tan relevante, puedes tenerlo todo en un map.

Esto es demostrativo.

Tambien nos va a servir tener un pipeline de ejecucion para la parte del Renderer

Como creamos ese pipeline de ejecución?

Necesitamos:

  1. una entidad Processor para guardar y ejecutar los Steps
  2. una entidad ProcessorStep con la transformacion especifica
processor.js
export class Processor {
constructor(steps = []) {
this.pipeline = steps;
}
run(data) {
let result = data;
for (const step of this.pipeline) {
result = step.process(result);
}
return result;
}
}
export class ProcessorStep {
// method to replace
process(data) {
return data(data);
}
}

Parser Steps

Pre-processing:

  1. Validamos que exista contenido
  2. Convertimos a JSON Object
  3. Validamos Schema del JSON
  4. devolvemos Objeto JSON
// PRE
class Parser_UI_Preprocessing extends ProcessorStep {
handle(txt) {
// data validation
if (!txt) throw new Error('Invalid File');
// format validation
const uiObject = JSON.parse(txt);
// schema validation
const schemaRules = [
uiObject.screen !== undefined,
uiObject.env !== undefined,
uiObject.metadata !== undefined,
];
const validSchema = schemaRules.every((r) => r === true);
if (!validSchema) {
console.log({ schemaRules });
throw new Error('Invalid Schema');
}
return uiObject;
}
}

Processing:

  1. El navegador en esta parte lee y detecta si tiene que cargar recursos externos ( no es nuestro caso)
  2. devuelve objeto
// PRO
class Parser_UI_Processing extends ProcessorStep {
handle(data) {
// load external resources
return data;
}
}

Post-processing:

  1. transformamos la posicion de los objetos de string a Array de números. position: "0,100" => position[0,100]
// POST
class Parser_UI_Postprocessing extends ProcessorStep {
handle(data) {
// sink data
data.screen.elements = data.screen.elements.map((el) => {
if (el.position)
el.position = el.position.split(',').map((coord) => Number(coord));
return el;
});
return data;
}
}

Ahora solo nos falta:

  1. instanciar el pipeline con los Steps
  2. ejecutar el pipeline
file-parser.js
const toUIHandler = {
txt: (txt) => {
// pipeline de ejecucion implementado
const pipeline = [
new Parser_UI_Preprocessing(),
new Parser_UI_Processing(),
new Parser_UI_Postprocessing(),
];
const TXTToUI = new Processor(pipeline);
return TXTToUI.run(txt);
},
xml: (xml) => {
throw new Error('Not implemented', { data: xml });
},
error: ({ from, file }) => {
throw new Error('Invalid transformation', { data: { from, file } });
},
};
export const fileParser = {
toUI: ({ from, file }) => {
// switch abreviado
return toUIHandler[from](file) || toUIHandler.error({ from, file });
},
};



# Recap

  1. ✅ Cuando el usuario seleccione un archivo desde su maquina
  2. ✅ Leemos el archivo
  3. ✅ Parseamos el archivo
  4. Actualizamos el state
  5. Dibujamos en pantalla


3. Actualizar el state

El state es la información de la aplicación.

Esto es lo que vamos a guardar:

state.js
export const appState = {
networking: {
online: true,
},
storage: {},
os: {},
users: [],
currentUser: 'default',
currentUI: undefined,
history: [],
};

Si volvemos al index.js

  • Creamos un objeto con la interfaz interpretada
  • la dejamos como currentUI
  • y la agregamos al historial del usuario
index.js
const initBrowser = async (event) => {
try {
// loader
const fileReference = event.target.files[0];
const uiTxt = await fileLoader.getFileAsTxt(fileReference);
// parser
const uiModel = await fileParser.toUI({
from: 'txt',
file: uiTxt,
});
// update state
const newUI = {
...uiModel,
ts: Date.now(),
};
appState.currentUI = newUI;
appState.history.push(newUI);
// -
// renderer
} catch (error) {
console.warn({ message: 'Unable to read file', error });
}
};
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', initBrowser);
});

Actualizamos el state ✅


4. Dibujar en pantalla, El último paso! yei!!!! 😬

El navegador realiza los siguientes pasos para dibujar en pantalla:

  1. Obtiene los elementos visibles de la UI
  2. Calcula la posicion de los elementos
  3. Pinta los elementos
  4. Mezcla todas las capas

este proceso se hace 60 veces por segundo.

60 veces en 1 segundo

Si ves por 3 segundos una pantalla “estática”.

La pantalla te mostró la misma imagen 180veces.

y Nosotros: 🥱 solo 1 imagen/sitio/pagina/app que no se mueve.



ejemplo


Para simular el movimiento, se crean imágenes secuenciales con pequeñas diferencias.

Que luego se muestran de forma “rapida”.


🤔🤔🤔 y en código?

Le decimos al Navegador que ejecute una funcion por frame.

let requestAnimationRef;
function byFrame() {
requestAnimationRef = window.requestAnimationFrame(byFrame);
// nuestra magia...
// esto es lo que se ejecuta 60 veces en 1 segundo.
}
byFrame();
// Amigo!, pare por favor!
cancelAnimationFrame(requestAnimationRef);

Esta función la puedes ver bajo nombres como:

  • update()
  • animate()

En Unity (motor de videjuegos)

Para agregar tu script al Loop, debes sobre-escribir la funcion Update() de una clase heredada.

(nombre clase:MonoBehaviour)

unity

En P5.js (creative-coding y proyectos multimedia)

se llama draw()

// Cuando inicia el programa y antes del loop
function setup() {
createCanvas(400, 400);
}
// cada frame
function draw() {
background(220);
}

En Phaser (JS para hacer videojuegos )

Tambien le llaman update()

var options = { preload: preload, create: create, update: update };
var game = new Phaser.Game(800, 600, Phaser.AUTO, 'my-game', options);
// declare other global variables (for sprites, etc.)
// preload game assets - runs one time when webpage first loads
function preload() {}
// create game world - runs one time after preload finishes
function create() {}
// update game - runs repeatedly in loop after create finishes
function update() {}



Implementemos:

Crearemos una clase Renderer que contendrá todo lo relacionado con dibujar en la pantalla.

engine: nuestro motor grafico, quien hablara con la tarjeta grafica y le dirá que dibujar.

en nuestro caso, el <canvas>

export class Renderer {
constructor(engine) {
if (!engine) throw new Error('engine not available');
// configurar engine
this.engine = engine;
this.engine.width = 300;
this.engine.height = 400;
// definir pipeline de dibujo
this.ctx = this.engine.getContext('2d');
this.drawProcess = new Processor([
new RendererStep_Layout(),
new RendererStep_Painting(this.engine, this.ctx),
new RendererStep_Composite(),
]);
}
// ejecutar pipeline de dibujo
draw(elements) {
return this.drawProcess.run(elements);
}
}

Pipeline de dibujo

  1. Layout
  2. Paint
  3. Composite

Layout

class RendererStep_Layout extends ProcessorStep {
handle(data) {
console.log('renderer:layout', data);
// calc elemnts position
// screen elements to UIControl
data.elements = data.elements.map((el) => new UIControl({ el }));
return data;
}
}

Painting

class RendererStep_Painting extends ProcessorStep {
constructor(engine, context) {
super();
this.engine = engine;
this.ctx = context;
}
handle(ui) {
console.log('renderer:painting', ui);
// setup
this.ctx.fillStyle = '#ccc';
this.ctx.fillRect(0, 0, ui.width, ui.height);
// draw
ui.elements.forEach((el) => el.draw(this.ctx));
return ui;
}
}

Composite

class RendererStep_Composite extends ProcessorStep {
handle(data) {
console.log('renderer:composite', data);
return data;
}
}

UIControl

Es nuestro Elemento visible. Es el Texto, Cuadrado, Poligono,etc.

class UIControl {
constructor({ el, config }) {
this.el = el;
this.config = config;
this.el.type = this.el.type.toLowerCase();
}
draw(context) {
try {
// switch abreviado para dibujar la forma segun el type
canvasHelper[this.el.type]({ ctx: context, config: this.config });
} catch (error) {
console.log({ error });
}
}
}

Canvas Helper

Helper para dibujar formas en el canvas.

si te fijas la mayoria dibuja un Rect, pero ya tenemos la carcasa

const canvasHelper = {
text: ({ ctx, config }) => {
ctx.beginPath();
ctx.fillStyle = 'green';
ctx.rect(10, 10, 50, 50);
ctx.stroke();
},
arc: ({ ctx, config }) => {
ctx.beginPath();
ctx.fillStyle = 'blue';
ctx.rect(100, 100, 50, 50);
ctx.fill();
ctx.stroke();
},
dot: ({ ctx, config }) => {
ctx.beginPath();
ctx.fillStyle = 'red';
ctx.rect(150, 150, 50, 50);
ctx.fill();
ctx.stroke();
},
rect: ({ ctx, config }) => {
ctx.beginPath();
ctx.fillStyle = 'purple';
ctx.rect(200, 200, 50, 50);
ctx.fill();
ctx.stroke();
},
polygon: ({ ctx, config }) => {
ctx.beginPath();
ctx.fillStyle = 'fucsia';
ctx.rect(10, 300, 50, 50);
ctx.fill();
ctx.stroke();
},
line({ ctx, config }) {
const { from, to } = {
from: { x: 10, y: 20 },
to: { x: 50, y: 50 },
};
ctx.strokeStyle = 'red';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
},
};


Con esto,

volvemos al index.js y terminamos la implementacion:

  1. creamos un Renderer y le pasamos la referencia del canvas
  2. Ejecutamos el pipeline de dibujo
index.js
import { fileLoader } from './modules/file-loader';
import { fileParser } from './modules/file-parser';
import { Renderer } from './modules/renderer';
import { appState } from './modules/state';
const initBrowser = async (event) => {
try {
// loader
const fileReference = event.target.files[0];
const uiTxt = await fileLoader.getFileAsTxt(fileReference);
// parser
const uiModel = await fileParser.toUI({
from: 'txt',
file: uiTxt,
});
// update state
const newUI = {
...uiModel,
ts: Date.now(),
};
appState.currentUI = newUI;
appState.history.push(newUI);
// renderer
const engine = document.querySelector('#engine');
const renderer = new Renderer(engine);
renderer.draw(appState.currentUI.screen);
} catch (error) {
console.warn({ message: 'Unable to read file', error });
}
};
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', initBrowser);
});


Terminamos !

Creamos nuestro propio interprete de interfaces!

  1. Definimos como se iba a escribir
  2. como se iba a interpretar
  3. y lo mostramos en pantalla

Le faltan cosas… of kors

Pero ya tenemos el CORE

  • file-loader
  • file-parser
  • state
  • renderer

😬😬😬

link demo

link repositorio

La próxima semana,

  • veremos como este CORE es el corazón de múltiples aplicaciones
  • y como seguir.

🤔 que puede ser entrete para agregarle?