Creando un Navegador: Parte II diagramas y código
a lo weno xD
Que vamos a hacer ?
- Crear la forma de escribir nuestras UIs
- Buscar Archivo UI
- Interpretar Archivo
- Guardar Archivo
- Dibujar Archivo
Flujo
(event) Cuando el usuario seleccione un archivo desde su maquina:
- Leemos el archivo
- Parseamos el archivo
- Actualizamos el state
- Dibujamos en pantalla
Folder structure
index.html
index.js
para iniciar nuestra implementaciónmodules/file-loader.js
para buscar archivosmodules/file-parser.js
para interpretar archivosmodules/state.js
para guardar el estadomodules/renderer.js
para dibujar nuestro state en pixeles
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.
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 Backendexport const loader = async () => { return json({ sandwiches: await db.sandwich.findMany(), });};// se ejecuta en el Frontendexport 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 Backendexport const onGet:RequestHandler<Contact[]> = async () =>{ return await Promise.resolve(CONTACTS);}// se ejecuta en el Frontendexport 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
default-ui.json
Crear la forma de escribir nuestras UIs ✅
Sigamos
Fujo
- Cuando el usuario seleccione un archivo desde su maquina:
- Leemos el archivo
- Parseamos el archivo
- Actualizamos el state
- Dibujamos en pantalla
0.Cuando el usuario seleccione un archivo desde su maquina:
- Vamos a escuchar el evento
change
del input file - Cuando ocurra, vamos a a ejecutar los pasos 1,2,3,4.
Nota: el evento no trae el archivo, trae la referencia del archivo.
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
.
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);});
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.
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
// formas de transformar en UI , desde TXT, XML, etcconst 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 procesamientoprocessing
: se procesa la datapost-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:
- una entidad
Processor
para guardar y ejecutar los Steps - una entidad
ProcessorStep
con la transformacion especifica
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:
- Validamos que exista contenido
- Convertimos a JSON Object
- Validamos Schema del JSON
- devolvemos Objeto JSON
// PREclass 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:
- El navegador en esta parte lee y detecta si tiene que cargar recursos externos ( no es nuestro caso)
- devuelve objeto
// PROclass Parser_UI_Processing extends ProcessorStep { handle(data) { // load external resources return data; }}
Post-processing:
- transformamos la posicion de los objetos de string a Array de números.
position: "0,100" => position[0,100]
// POSTclass 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:
- instanciar el pipeline con los Steps
- ejecutar el pipeline
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
- ✅ Cuando el usuario seleccione un archivo desde su maquina
- ✅ Leemos el archivo
- ✅ Parseamos el archivo
- Actualizamos el state
- Dibujamos en pantalla
3. Actualizar el state
El state es la información de la aplicación.
Esto es lo que vamos a guardar:
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
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:
- Obtiene los elementos visibles de la UI
- Calcula la posicion de los elementos
- Pinta los elementos
- 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
.
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
)
En P5.js (creative-coding y proyectos multimedia)
se llama draw()
// Cuando inicia el programa y antes del loopfunction setup() { createCanvas(400, 400);}// cada framefunction 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 loadsfunction preload() {}
// create game world - runs one time after preload finishesfunction create() {}
// update game - runs repeatedly in loop after create finishesfunction 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
- Layout
- Paint
- 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:
- creamos un
Renderer
y le pasamos la referencia del canvas - Ejecutamos el pipeline de dibujo
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!
- Definimos como se iba a escribir
- como se iba a interpretar
- y lo mostramos en pantalla
Le faltan cosas… of kors
Pero ya tenemos el CORE
- file-loader
- file-parser
- state
- renderer
😬😬😬
La próxima semana,
- veremos como este CORE es el corazón de múltiples aplicaciones
- y como seguir.