Skip to content

Commit

Permalink
feat: support ssr + hydration
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed May 25, 2022
1 parent 0637628 commit 098aa89
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 44 deletions.
9 changes: 7 additions & 2 deletions src/Repl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ export interface Props {
clearConsole?: boolean
sfcOptions?: SFCOptions
layout?: string
ssr?: boolean
}
const props = withDefaults(defineProps<Props>(), {
store: () => new ReplStore(),
autoResize: true,
showCompileOutput: true,
showImportMap: true,
clearConsole: true
clearConsole: true,
ssr: false
})
props.store.options = props.sfcOptions
Expand All @@ -38,7 +40,10 @@ provide('clear-console', toRef(props, 'clearConsole'))
<Editor />
</template>
<template #right>
<Output :showCompileOutput="props.showCompileOutput" />
<Output
:showCompileOutput="props.showCompileOutput"
:ssr="!!props.ssr"
/>
</template>
</SplitPane>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ export { default as Repl } from './Repl.vue'
export { ReplStore, File } from './store'
export { compileFile } from './transform'
export type { Props as ReplProps } from './Repl.vue'
export type { Store, SFCOptions, StoreState } from './store'
export type { Store, StoreOptions, SFCOptions, StoreState } from './store'
export type { OutputModes } from './output/types'
3 changes: 2 additions & 1 deletion src/output/Output.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { OutputModes } from './types'
const props = defineProps<{
showCompileOutput?: boolean
ssr: boolean
}>()
const store = inject('store') as Store
Expand Down Expand Up @@ -35,7 +36,7 @@ const mode = ref<OutputModes>(
</div>

<div class="output-container">
<Preview :show="mode === 'preview'" />
<Preview :show="mode === 'preview'" :ssr="ssr" />
<CodeMirror
v-if="mode !== 'preview'"
readonly
Expand Down
61 changes: 50 additions & 11 deletions src/output/Preview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { PreviewProxy } from './PreviewProxy'
import { compileModulesForPreview } from './moduleCompiler'
import { Store } from '../store'
defineProps<{ show: boolean }>()
const props = defineProps<{ show: boolean; ssr: boolean }>()
const store = inject('store') as Store
const clearConsole = inject('clear-console') as Ref<boolean>
Expand Down Expand Up @@ -160,30 +160,69 @@ async function updatePreview() {
runtimeError.value = null
runtimeWarning.value = null
const isSSR = props.ssr
try {
const mainFile = store.state.mainFile
// if SSR, generate the SSR bundle and eval it to render the HTML
if (isSSR && mainFile.endsWith('.vue')) {
const ssrModules = compileModulesForPreview(store, true)
console.log(
`[@vue/repl] successfully compiled ${ssrModules.length} modules for SSR.`
)
await proxy.eval([
`const __modules__ = {};`,
...ssrModules,
`import { renderToString as _renderToString } from 'vue/server-renderer'
import { createSSRApp as _createApp } from 'vue'
const AppComponent = __modules__["${mainFile}"].default
AppComponent.name = 'Repl'
const app = _createApp(AppComponent)
app.config.unwrapInjectedRef = true
app.config.warnHandler = () => {}
window.__ssr_promise__ = _renderToString(app).then(html => {
document.body.innerHTML = '<div id="app">' + html + '</div>'
}).catch(err => {
console.error("SSR Error", err)
})
`
])
}
// compile code to simulated module system
const modules = compileModulesForPreview(store)
console.log(`[@vue/repl] successfully compiled ${modules.length} modules.`)
const codeToEval = [
`window.__modules__ = {};window.__css__ = '';` +
`if (window.__app__) window.__app__.unmount();` +
`document.body.innerHTML = '<div id="app"></div>'`,
`if (window.__app__) window.__app__.unmount();` +
isSSR
? ``
: `document.body.innerHTML = '<div id="app"></div>'`,
...modules,
`document.getElementById('__sfc-styles').innerHTML = window.__css__`
]
// if main file is a vue file, mount it.
const mainFile = store.state.mainFile
if (mainFile.endsWith('.vue')) {
codeToEval.push(
`import { createApp as _createApp } from "vue"
const AppComponent = __modules__["${mainFile}"].default
AppComponent.name = 'Repl'
const app = window.__app__ = _createApp(AppComponent)
app.config.unwrapInjectedRef = true
app.config.errorHandler = e => console.error(e)
app.mount('#app')`.trim()
`import { ${
isSSR ? `createSSRApp` : `createApp`
} as _createApp } from "vue"
const _mount = () => {
const AppComponent = __modules__["${mainFile}"].default
AppComponent.name = 'Repl'
const app = window.__app__ = _createApp(AppComponent)
app.config.unwrapInjectedRef = true
app.config.errorHandler = e => console.error(e)
app.mount('#app')
}
if (window.__ssr_promise__) {
window.__ssr_promise__.then(_mount)
} else {
_mount()
}`
)
}
Expand Down
43 changes: 26 additions & 17 deletions src/output/moduleCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,27 @@ import {
} from 'vue/compiler-sfc'
import { ExportSpecifier, Identifier, Node } from '@babel/types'

export function compileModulesForPreview(store: Store) {
export function compileModulesForPreview(store: Store, isSSR = false) {
const seen = new Set<File>()
const processed: string[] = []
processFile(store, store.state.files[store.state.mainFile], processed, seen)
processFile(
store,
store.state.files[store.state.mainFile],
processed,
seen,
isSSR
)

// also add css files that are not imported
for (const filename in store.state.files) {
if (filename.endsWith('.css')) {
const file = store.state.files[filename]
if (!seen.has(file)) {
processed.push(
`\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
)
if (!isSSR) {
// also add css files that are not imported
for (const filename in store.state.files) {
if (filename.endsWith('.css')) {
const file = store.state.files[filename]
if (!seen.has(file)) {
processed.push(
`\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
)
}
}
}
}
Expand All @@ -40,30 +48,31 @@ function processFile(
store: Store,
file: File,
processed: string[],
seen: Set<File>
seen: Set<File>,
isSSR: boolean
) {
if (seen.has(file)) {
return []
}
seen.add(file)

if (file.filename.endsWith('.html')) {
if (!isSSR && file.filename.endsWith('.html')) {
return processHtmlFile(store, file.code, file.filename, processed, seen)
}

let [js, importedFiles] = processModule(
store,
file.compiled.js,
isSSR ? file.compiled.ssr : file.compiled.js,
file.filename
)
// append css
if (file.compiled.css) {
if (!isSSR && file.compiled.css) {
js += `\nwindow.__css__ += ${JSON.stringify(file.compiled.css)}`
}
// crawl child imports
if (importedFiles.size) {
for (const imported of importedFiles) {
processFile(store, store.state.files[imported], processed, seen)
processFile(store, store.state.files[imported], processed, seen, isSSR)
}
}
// push self
Expand Down Expand Up @@ -111,7 +120,7 @@ function processModule(

// 0. instantiate module
s.prepend(
`const ${moduleKey} = __modules__[${JSON.stringify(
`const ${moduleKey} = ${modulesKey}[${JSON.stringify(
filename
)}] = { [Symbol.toStringTag]: "Module" }\n\n`
)
Expand Down Expand Up @@ -283,7 +292,7 @@ function processHtmlFile(
const [code, importedFiles] = processModule(store, content, filename)
if (importedFiles.size) {
for (const imported of importedFiles) {
processFile(store, store.state.files[imported], deps, seen)
processFile(store, store.state.files[imported], deps, seen, false)
}
}
jsCode += '\n' + code
Expand Down
35 changes: 26 additions & 9 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface StoreState {
activeFile: File
errors: (string | Error)[]
vueRuntimeURL: string
vueServerRendererURL: string
}

export interface SFCOptions {
Expand All @@ -67,6 +68,15 @@ export interface Store {
initialOutputMode: OutputModes
}

export interface StoreOptions {
serializedState?: string
showOutput?: boolean
// loose type to allow getting from the URL without inducing a typing error
outputMode?: OutputModes | string
defaultVueRuntimeURL?: string
defaultVueServerRendererURL?: string
}

export class ReplStore implements Store {
state: StoreState
compiler = defaultCompiler
Expand All @@ -75,20 +85,16 @@ export class ReplStore implements Store {
initialOutputMode: OutputModes

private defaultVueRuntimeURL: string
private defaultVueServerRendererURL: string
private pendingCompiler: Promise<any> | null = null

constructor({
serializedState = '',
defaultVueRuntimeURL = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`,
defaultVueServerRendererURL = `https://unpkg.com/@vue/server-renderer@${version}/dist/server-renderer.esm-browser.js`,
showOutput = false,
outputMode = 'preview'
}: {
serializedState?: string
showOutput?: boolean
// loose type to allow getting from the URL without inducing a typing error
outputMode?: OutputModes | string
defaultVueRuntimeURL?: string
} = {}) {
}: StoreOptions = {}) {
let files: StoreState['files'] = {}

if (serializedState) {
Expand All @@ -103,6 +109,7 @@ export class ReplStore implements Store {
}

this.defaultVueRuntimeURL = defaultVueRuntimeURL
this.defaultVueServerRendererURL = defaultVueServerRendererURL
this.initialShowOutput = showOutput
this.initialOutputMode = outputMode as OutputModes

Expand All @@ -115,7 +122,8 @@ export class ReplStore implements Store {
files,
activeFile: files[mainFile],
errors: [],
vueRuntimeURL: this.defaultVueRuntimeURL
vueRuntimeURL: this.defaultVueRuntimeURL,
vueServerRendererURL: this.defaultVueServerRendererURL
})

this.initImportMap()
Expand Down Expand Up @@ -202,6 +210,10 @@ export class ReplStore implements Store {
json.imports.vue = this.defaultVueRuntimeURL
map.code = JSON.stringify(json, null, 2)
}
if (!json.imports['vue/server-renderer']) {
json.imports['vue/server-renderer'] = this.defaultVueServerRendererURL
map.code = JSON.stringify(json, null, 2)
}
} catch (e) {}
}
}
Expand All @@ -227,18 +239,23 @@ export class ReplStore implements Store {
async setVueVersion(version: string) {
const compilerUrl = `https://unpkg.com/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
const runtimeUrl = `https://unpkg.com/@vue/runtime-dom@${version}/dist/runtime-dom.esm-browser.js`
const ssrUrl = `https://unpkg.com/@vue/server-renderer@${version}/dist/server-renderer.esm-browser.js`
this.pendingCompiler = import(/* @vite-ignore */ compilerUrl)
this.compiler = await this.pendingCompiler
this.pendingCompiler = null
this.state.vueRuntimeURL = runtimeUrl
this.state.vueServerRendererURL = ssrUrl
const importMap = this.getImportMap()
;(importMap.imports || (importMap.imports = {})).vue = runtimeUrl
const imports = importMap.imports || (importMap.imports = {})
imports.vue = runtimeUrl
imports['vue/server-renderer'] = ssrUrl
this.setImportMap(importMap)
console.info(`[@vue/repl] Now using Vue version: ${version}`)
}

resetVueVersion() {
this.compiler = defaultCompiler
this.state.vueRuntimeURL = this.defaultVueRuntimeURL
this.state.vueServerRendererURL = this.defaultVueServerRendererURL
}
}
2 changes: 2 additions & 0 deletions src/vue-server-renderer-dev-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// serve server renderer to the iframe sandbox during dev.
export * from 'vue/server-renderer'
9 changes: 6 additions & 3 deletions test/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createApp, h, watchEffect } from 'vue'
import { Repl, ReplStore } from '../src'

;(window as any).process = { env: {} }

const App = {
Expand All @@ -12,7 +11,10 @@ const App = {
outputMode: query.get('om') || 'preview',
defaultVueRuntimeURL: import.meta.env.PROD
? undefined
: `${location.origin}/src/vue-dev-proxy`
: `${location.origin}/src/vue-dev-proxy`,
defaultVueServerRendererURL: import.meta.env.PROD
? undefined
: `${location.origin}/src/vue-server-renderer-dev-proxy`
})

watchEffect(() => history.replaceState({}, '', store.serialize()))
Expand All @@ -35,7 +37,8 @@ const App = {
return () =>
h(Repl, {
store,
layout: 'vertical',
// layout: 'vertical',
ssr: true,
sfcOptions: {
script: {
// inlineTemplate: false
Expand Down

0 comments on commit 098aa89

Please sign in to comment.