Migração e Integração
Migração e Integração
Section titled “Migração e Integração”Este guia cobre como adotar Slash em diferentes contextos: migrando de outras bibliotecas, integrando com projetos existentes e configurando ferramentas de build.
Instalação
Section titled “Instalação”bun add @_bashell/slashnpm install @_bashell/slashpnpm add @_bashell/slashyarn add @_bashell/slash<!-- ES Module (via esm.sh) --><script type="module"> import { html, createState } from 'https://esm.sh/@_bashell/slash'</script>Migrando para 0.0.3
Section titled “Migrando para 0.0.3”A 0.0.3 torna o core seguro por padrão. Uma string é sempre texto, URLs e handlers são verificados e marcação confiável precisa de um tipo explícito. Isso traz mudanças incompatíveis: use a tabela abaixo para achar e corrigir cada caso. A explicação completa está em Segurança.
| Se o seu código… | O que acontece agora | Como migrar |
|---|---|---|
usa htmlString`...` como string (.length, +, .startsWith, res.send(x)) | ele devolve um SafeHtml | use String(x) ou renderToString(() => x).html |
devolve HTML montado à mão de um componente ou helper (return "<div>...</div>", Markdown, ícones) | a string aparece como texto visível | devolva um template htmlString/html; para marcação confiável use unsafeHtml(str) |
passa innerHTML=${...}, outerHTML=... ou srcdoc=${...} como prop | a prop é ignorada (aviso em dev) | use unsafeHtml(...) como filho; srcdoc=${unsafeHtml(...)} |
usa javascript:, data:text/html, file:, whatsapp:, ftp: ou outros esquemas próprios em href/src/action (passam http, https, mailto, tel, sms; blob: em src de mídia; data:image/svg+xml em img e url() de CSS) | o valor vira about:blank#blocked | use uma URL real, ou unsafeUrl(url) para uma URL confiável; valide entradas com sanitizeUrl |
usa <${Link} to="./x">, to="../x", to="about" ou uma URL absoluta | não navega, href bloqueado | use um caminho do app (/x, ?q, #h); para site externo, adicione external |
usa handlers em string (onclick="...") ou um atributo comum que comece com on | toda prop on* que não seja função, objeto handler ou [fn, opções] é descartada (cliente e SSR) | passe uma função: onClick=${fn}; use data- nos atributos comuns |
escreve <script>${json}</script> ou <style>${css}</style> com valores dinâmicos (html ou htmlString) | strings, números, arrays, componentes e reativos dinâmicos são descartados, no cliente e no SSR (aviso em dev) | unsafeHtml(serializeStateForScript(dados)) para JSON; mantenha CSS/JS estáticos |
coloca um < literal num <script> estático dentro do htmlString (if (a < b)) | o htm lê como tag | mova esse código para unsafeHtml(...) |
usa <script id="__SLASH_STATE__"> sem type | o render() ignora o script (aviso em dev) | adicione type="application/json" |
passa props com nome de método do DOM (click, focus) ou constructor | a prop de método vira atributo e constructor é bloqueada | use onClick=${fn} para eventos |
| tem estado circular ou aninhado em mais de 1000 níveis | createState lança State is circular or nested deeper than 1000 levels | achate o estado ou guarde ids em vez de referências |
compara mensagens de runtime em português (URL bloqueada…) em testes | as mensagens agora são em inglês | atualize o texto esperado |
depende de o roteador interceptar links mailto:, tel: ou de outra origem, ou de ?q/#h relativos à rota | só links http(s) da mesma origem são interceptados; ?q e #h são relativos à página atual | links normais não mudam; use external para outros sites |
interpola um State direto no SSR (${state}) | um State não é reativo no servidor | interpole state.get() |
embute estado com JSON.stringify(state) num <script> | quebra com </script> | serializeStateForScript(state) |
chama __addBatchEndCallback, __removeBatchEndCallback ou __recordBatchUpdate | removidos | use batch() e state.watch() |
monta um RouterInstance à mão (mock) | ready é obrigatório | adicione ready: Promise.resolve() |
chama data.hasOwnProperty(...) em formToObject() ou router.query | esses objetos não têm protótipo | Object.hasOwn(data, "campo") |
usava strings style="..." no cliente aplicadas como vieram | declarações inseguras são descartadas | use só valores CSS simples: sem comentários /* */, escapes só dentro de aspas, url("a b.png") com aspas se houver caracteres especiais, no máximo 8 KB |
Quanto aos builds: Vite (dev) e webpack (mode: "development") usam sozinhos o build de desenvolvimento, que mostra um aviso para cada bloqueio. O build de produção não traz esses avisos, mas mantém console.error para erros reais. Para ver os avisos fora de um bundler, rode com --conditions=development.
Buscas rápidas para achar os casos no seu projeto:
grep -rnE "htmlString|innerHTML=|srcdoc=|href=\$\{|onclick=|JSON.stringify\(state" srcMigrando de React
Section titled “Migrando de React”Mapeamento de Conceitos
Section titled “Mapeamento de Conceitos”| React | Slash | Exemplo |
|---|---|---|
useState() | createState() | const [count, setCount] = useState(0) → const count = createState(0) |
useEffect() | state.watch() ou código direto | Ver exemplos abaixo |
useMemo() | Funções normais | Sem cache automático; calcule a partir de state.get() |
useCallback() | Funções normais | Não necessário |
useRef() | Variável fora do componente | O componente re-executa, então variáveis locais são recriadas |
useContext() / useState() | Estado fora do componente | createState() no módulo; não há estado local por instância |
props | Parâmetros de função | Idêntico |
| JSX | html template tag | Ver exemplos |
dangerouslySetInnerHTML | unsafeHtml(...) como filho | innerHTML é bloqueado como prop; unsafeHtml não sanitiza (veja Segurança) |
Exemplo Prático de Migração
Section titled “Exemplo Prático de Migração”// Reactimport { useState } from 'react'
function Counter({ initialCount = 0 }) { const [count, setCount] = useState(initialCount)
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> <button onClick={() => setCount(count - 1)}> Decrement </button> </div> )}// Slashimport { html, createState, type State } from '@_bashell/slash'
interface CounterProps { count: State<number> // o state é criado fora e passado por props}
// Monte como <${Counter} count=${count} />function Counter({ count }: CounterProps) { return html` <div> <p>Count: ${count.get()}</p> <button onClick=${() => count.set(count.get() + 1)}> Increment </button> <button onClick=${() => count.set(count.get() - 1)}> Decrement </button> </div> `}
const count = createState(0) // valor inicial = o initialCount do React// Reactimport { useState, useEffect } from 'react'
function Example() { const [count, setCount] = useState(0)
useEffect(() => { console.log('Count changed:', count)
return () => { console.log('Cleanup') } }, [count])
return <p>{count}</p>}// Slashimport { html, createState } from '@_bashell/slash'
const count = createState(0)
// O efeito vive fora do componente (que re-executa a cada mudança)const unwatch = count.watch((newVal) => { console.log('Count changed:', newVal)})// unwatch() quando não precisar mais: não há onCleanup público
function Example() { return html`<p>${count.get()}</p>`}// Reactimport { useState } from 'react'
function LoginForm() { const [email, setEmail] = useState('') const [password, setPassword] = useState('')
const handleSubmit = (e) => { e.preventDefault() console.log({ email, password }) }
return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <button type="submit">Login</button> </form> )}// Slashimport { html, createState } from '@_bashell/slash'
const form = createState({ email: '', password: '' })
// Os campos não leem o state no render, então não são recriados a cada teclafunction LoginForm() { const handleSubmit = (e: Event) => { e.preventDefault() console.log(form.get()) }
return html` <form onSubmit=${handleSubmit}> <input type="email" onInput=${(e) => form.set({ ...form.get(), email: e.target.value })} /> <input type="password" onInput=${(e) => form.set({ ...form.get(), password: e.target.value })} /> <button type="submit">Login</button> </form> `}// Reactimport { useState } from 'react'
function TodoList() { const [todos, setTodos] = useState([ { id: 1, text: 'Learn React' }, { id: 2, text: 'Build app' } ])
return ( <ul> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> )}// Slashimport { html, createState } from '@_bashell/slash'
const todos = createState([ { id: 1, text: 'Learn Slash' }, { id: 2, text: 'Build app' }])
function TodoList() { return html` <ul> ${todos.get().map(todo => html` <li key=${todo.id}>${todo.text}</li> `)} </ul> `}Estratégia de Migração Incremental
Section titled “Estratégia de Migração Incremental”Você pode migrar componente por componente sem reescrever tudo:
- Identifique componentes folha (sem filhos React)
- Migre de baixo para cima
- Use micro-frontends se necessário
// React component que usa Slash componentimport { useEffect, useRef } from 'react'import { html, render } from '@_bashell/slash'import { SlashComponent } from './slash-components'
function ReactWrapper() { const containerRef = useRef(null)
useEffect(() => { if (containerRef.current) { render(html`<${SlashComponent} />`, containerRef.current) } }, [])
return <div ref={containerRef} />}Migrando de Vue
Section titled “Migrando de Vue”Mapeamento de Conceitos
Section titled “Mapeamento de Conceitos”| Vue 3 | Slash | Notas |
|---|---|---|
ref() | createState() | .value vs .get()/.set() |
computed() | Funções | Compute on-demand |
watch() | state.watch() | API similar |
v-if | Ternário ? : | JS nativo |
v-for | .map() | JS nativo |
v-model | onInput + state.set() (ou textFieldControl) | Manual binding |
onMounted() | Código fora do componente | O corpo do componente re-executa; não há hook de montagem |
onUnmounted() | Sem equivalente público | Gerencie timers/listeners você mesmo |
| Template | html tag | HTM syntax |
v-html | unsafeHtml(...) como filho | Só para marcação confiável e sanitizada |
Exemplo de Migração
Section titled “Exemplo de Migração”<!-- Vue 3 --><script setup>import { ref, computed } from 'vue'
const count = ref(0)const doubled = computed(() => count.value * 2)
function increment() { count.value++}</script>
<template> <div> <p>Count: {{ count }}</p> <p>Doubled: {{ doubled }}</p> <button @click="increment">+</button> </div></template>// Slashimport { html, createState } from '@_bashell/slash'
const count = createState(0)const doubled = () => count.get() * 2
const increment = () => { count.set(count.get() + 1)}
// Monte como <${Counter} />function Counter() { return html` <div> <p>Count: ${count.get()}</p> <p>Doubled: ${doubled()}</p> <button onClick=${increment}>+</button> </div> `}Integração com Projetos Existentes
Section titled “Integração com Projetos Existentes”1. Adicionar Slash a Projeto Vanilla JS
Section titled “1. Adicionar Slash a Projeto Vanilla JS”<!DOCTYPE html><html><head> <title>My App</title></head><body> <div id="app"></div>
<script type="module"> import { html, render, createState } from 'https://esm.sh/@_bashell/slash'
// O state fica fora do componente const count = createState(0)
const App = () => html` <div> <h1>Hello Slash!</h1> <p>Count: ${count.get()}</p> <button onClick=${() => count.set(count.get() + 1)}> Increment </button> </div> `
// Monte como <${App} /> para o componente ser reativo render(html`<${App} />`, document.getElementById('app')) </script></body></html>2. Integrar com jQuery/Vanilla
Section titled “2. Integrar com jQuery/Vanilla”Slash pode conviver com jQuery ou vanilla JS:
import { html, render, createState } from '@_bashell/slash'
// Estado Slashconst globalState = createState({ user: null, isLoggedIn: false})
// Component Slashfunction UserWidget() { const state = globalState.get()
if (!state.isLoggedIn) { return html`<button onClick=${showLoginModal}>Login</button>` }
return html`<p>Welcome, ${state.user.name}!</p>`}
// Integração com jQueryfunction showLoginModal() { $('#loginModal').modal('show')}
$('#loginForm').on('submit', function(e) { e.preventDefault()
// Atualiza estado Slash globalState.set({ user: { name: 'John' }, isLoggedIn: true })
$('#loginModal').modal('hide')})
// Render componente Slash// Monte como <${UserWidget} />: ele lê globalState e re-renderiza quando o state mudarender(html`<${UserWidget} />`, document.getElementById('user-widget'))3. Web Components
Section titled “3. Web Components”Encapsule Slash em Web Components:
import { html, render, createState } from '@_bashell/slash'
class CounterElement extends HTMLElement { connectedCallback() { // connectedCallback roda de novo se o elemento for movido ou reinserido: // não chame attachShadow (lança erro) nem monte duas vezes if (this.shadowRoot) return const shadow = this.attachShadow({ mode: 'open' })
// O state vive na instância, fora do componente const count = createState(0)
const Counter = () => html` <div> <p>Count: ${count.get()}</p> <button onClick=${() => count.set(count.get() + 1)}> Increment </button> </div> `
// render() exige um Element como container (ShadowRoot não serve) const mount = document.createElement('div') shadow.appendChild(mount) render(html`<${Counter} />`, mount) }}
customElements.define('slash-counter', CounterElement)Uso:
<slash-counter></slash-counter>Build Tools
Section titled “Build Tools”bun create vite my-app --template vanilla-tscd my-appbun add @_bashell/slashimport { html, render, createState } from '@_bashell/slash'import './style.css'
const count = createState(0)
const App = () => html` <div> <h1>Vite + Slash</h1> <p>Count: ${count.get()}</p> <button onClick=${() => count.set(count.get() + 1)}> Increment </button> </div>`
const root = document.querySelector('#app')if (root) { render(html`<${App} />`, root)}import { defineConfig } from 'vite'
export default defineConfig({ // Configuração padrão funciona!})bun initbun add @_bashell/slashimport { html, render, createState } from '@_bashell/slash'
const count = createState(0)
const App = () => html` <div> <h1>Bun + Slash</h1> <button onClick=${() => count.set(count.get() + 1)}> Count: ${count.get()} </button> </div>`
render(html`<${App} />`, document.body){ "scripts": { "dev": "bun run --watch index.ts", "build": "bun build index.ts --outdir ./dist --minify" }}Webpack
Section titled “Webpack”npm install --save-dev webpack webpack-cli webpack-dev-servernpm install @_bashell/slashconst path = require('path')
module.exports = { entry: './src/index.ts', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') }, module: { rules: [ { test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ } ] }, resolve: { extensions: ['.ts', '.js'] }, devServer: { static: './dist' }}Parcel
Section titled “Parcel”npm install --save-dev parcelnpm install @_bashell/slash<!DOCTYPE html><html><head> <title>Parcel + Slash</title></head><body> <div id="app"></div> <script type="module" src="./src/index.ts"></script></body></html>{ "scripts": { "dev": "parcel index.html", "build": "parcel build index.html" }}esbuild
Section titled “esbuild”npm install --save-dev esbuildnpm install @_bashell/slashrequire('esbuild').build({ entryPoints: ['src/index.ts'], bundle: true, outfile: 'dist/bundle.js', minify: true, sourcemap: true}).catch(() => process.exit(1)){ "scripts": { "build": "node build.js" }}TypeScript Configuration
Section titled “TypeScript Configuration”{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM"], "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "types": ["vite/client"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}SSR Setup
Section titled “SSR Setup”Node.js + Express
Section titled “Node.js + Express”bun add express @_bashell/slashbun add -d @types/expressimport express from 'express'import { renderToString, serializeStateForScript } from '@_bashell/slash/ssr'import { App } from './App' // App usa htmlString no servidor
const app = express()
app.get('*', (req, res) => { // renderToString devolve { html, state } const { html: appHtml, state } = renderToString(() => App())
// O documento é um template literal comum (htmlString com <!DOCTYPE> não funciona). // O título é fixo aqui; se vier de dado de usuário, escape-o à mão. const html = `<!DOCTYPE html><html> <head> <title>SSR App</title> <script type="module" src="/client.js"></script> </head> <body> <div id="app">${appHtml}</div> <script id="__SLASH_STATE__" type="application/json">${serializeStateForScript(state)}</script> </body></html>`
res.send(html)})
app.listen(3000, () => { console.log('Server running on http://localhost:3000')})Bun Server
Section titled “Bun Server”import { renderToString, serializeStateForScript } from '@_bashell/slash/ssr'import { App } from './App' // App usa htmlString no servidor
Bun.serve({ port: 3000, fetch(req) { const { html: appHtml, state } = renderToString(() => App())
// Template literal comum para o documento (não use htmlString com <!DOCTYPE>) const html = `<!DOCTYPE html><html> <head> <title>SSR App</title> </head> <body> <div id="app">${appHtml}</div> <script id="__SLASH_STATE__" type="application/json">${serializeStateForScript(state)}</script> <script type="module" src="/client.js"></script> </body></html>`
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) }})Estrutura de Projeto Recomendada
Section titled “Estrutura de Projeto Recomendada”SPA (Client-Side)
Section titled “SPA (Client-Side)”my-app/├── src/│ ├── components/│ │ ├── Header.ts│ │ ├── Footer.ts│ │ └── TodoItem.ts│ ├── pages/│ │ ├── Home.ts│ │ ├── About.ts│ │ └── NotFound.ts│ ├── state/│ │ └── todoState.ts│ ├── router.ts│ ├── App.ts│ └── main.ts├── public/│ └── styles.css├── index.html├── package.json└── tsconfig.jsonSSR (Universal)
Section titled “SSR (Universal)”my-app/├── src/│ ├── shared/│ │ ├── components/│ │ ├── loaders/│ │ └── types.ts│ ├── server/│ │ ├── index.ts│ │ └── routes.ts│ ├── client/│ │ └── index.ts│ └── App.ts├── package.json└── tsconfig.jsonTesting
Section titled “Testing”Com Bun Test
Section titled “Com Bun Test”import { test, expect } from 'bun:test'import { createState } from '@_bashell/slash'
test('counter increments', () => { const count = createState(0)
expect(count.get()).toBe(0)
count.set(5) expect(count.get()).toBe(5)})
test('state watches changes', () => { const count = createState(0) let called = false
count.watch((newVal) => { called = true expect(newVal).toBe(10) })
count.set(10) expect(called).toBe(true)})Com Vitest
Section titled “Com Vitest”bun add -d vitest happy-domimport { defineConfig } from 'vitest/config'
export default defineConfig({ test: { environment: 'happy-dom' }})Dicas de Migração
Section titled “Dicas de Migração”1. Comece Pequeno
Section titled “1. Comece Pequeno”Não reescreva tudo de uma vez. Comece com:
- Componentes novos
- Componentes folha (sem filhos)
- Features isoladas
2. Use TypeScript
Section titled “2. Use TypeScript”Slash tem tipos excelentes. Use para facilitar migração:
// Defina interfaces para seus dadosinterface User { id: string name: string email: string}
const user = createState<User | null>(null)3. Mantenha Estado Global Separado
Section titled “3. Mantenha Estado Global Separado”import { createState } from '@_bashell/slash'
export const authState = createState({ user: null, isAuthenticated: false})
export const uiState = createState({ sidebarOpen: false, theme: 'light'})4. Crie Helpers Reutilizáveis
Section titled “4. Crie Helpers Reutilizáveis”Chame esses helpers fora dos componentes (no módulo): um createState() dentro de um componente é recriado a cada render.
import { createState } from '@_bashell/slash'
export function createFormField<T>(initialValue: T) { const value = createState(initialValue) const error = createState<string | null>(null) const touched = createState(false)
return { value, error, touched, reset: () => { value.set(initialValue) error.set(null) touched.set(false) } }}Próximos Passos
Section titled “Próximos Passos”- Exemplos Práticos - Apps completas
- API Reference - Documentação da API
- Comparações - Slash vs outras libs