Skip to content

Commit

Permalink
feat: hmr propagation
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed Apr 23, 2020
1 parent 1e4a78c commit 6e66766
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 68 deletions.
183 changes: 119 additions & 64 deletions src/server/plugins/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import chokidar from 'chokidar'
import { SFCBlock } from '@vue/compiler-sfc'
import { parseSFC, vueCache } from './vue'
import { cachedRead } from '../utils'
import { importerMap } from './modules'

const hmrClientPath = path.resolve(__dirname, '../../client/client.js')

Expand Down Expand Up @@ -43,87 +44,141 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
}
})

const notify = (payload: HMRPayload) =>
sockets.forEach((s) => s.send(JSON.stringify(payload)))
const notify = (payload: HMRPayload) => {
const stringified = JSON.stringify(payload)
console.log(`[hmr] ${stringified}`)
sockets.forEach((s) => s.send(stringified))
}

const watcher = chokidar.watch(root, {
ignored: [/node_modules/]
})

watcher.on('change', async (file) => {
const resourcePath = '/' + path.relative(root, file)
const send = (payload: HMRPayload) => {
console.log(`[hmr] ${JSON.stringify(payload)}`)
notify(payload)
}

const servedPath = '/' + path.relative(root, file)
if (file.endsWith('.vue')) {
const cacheEntry = vueCache.get(file)
vueCache.del(file)

const descriptor = await parseSFC(root, file)
if (!descriptor) {
// read failed
return
handleVueSFCReload(file, servedPath)
} else {
// normal js file
const importers = importerMap.get(servedPath)
if (importers) {
const vueImporters = new Set<string>()
const jsHotImporters = new Set<string>()
const hasDeadEnd = walkImportChain(
importers,
vueImporters,
jsHotImporters
)

if (hasDeadEnd) {
notify({
type: 'full-reload'
})
} else {
vueImporters.forEach((vueImporter) => {
notify({
type: 'reload',
path: vueImporter
})
})
console.log(jsHotImporters)
}
}
}
})

const prevDescriptor = cacheEntry && cacheEntry.descriptor
if (!prevDescriptor) {
// the file has never been accessed yet
return
function walkImportChain(
currentImporters: Set<string>,
vueImporters: Set<string>,
jsHotImporters: Set<string>
): boolean {
let hasDeadEnd = false
for (const importer of currentImporters) {
if (importer.endsWith('.vue')) {
vueImporters.add(importer)
} else if (isHotBoundary(importer)) {
jsHotImporters.add(importer)
} else {
const parentImpoters = importerMap.get(importer)
if (!parentImpoters) {
hasDeadEnd = true
} else {
hasDeadEnd = walkImportChain(
parentImpoters,
vueImporters,
jsHotImporters
)
}
}
}
return hasDeadEnd
}

// check which part of the file changed
if (!isEqual(descriptor.script, prevDescriptor.script)) {
send({
type: 'reload',
path: resourcePath
})
return
}
function isHotBoundary(servedPath: string): boolean {
// TODO
return false
}

if (!isEqual(descriptor.template, prevDescriptor.template)) {
send({
type: 'rerender',
path: resourcePath
})
return
}
async function handleVueSFCReload(file: string, servedPath: string) {
const cacheEntry = vueCache.get(file)
vueCache.del(file)

const prevStyles = prevDescriptor.styles || []
const nextStyles = descriptor.styles || []
if (
prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)
) {
send({
type: 'reload',
path: resourcePath
})
}
const styleId = hash_sum(resourcePath)
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
send({
type: 'style-update',
path: resourcePath,
index: i,
id: `${styleId}-${i}`
})
}
const descriptor = await parseSFC(root, file)
if (!descriptor) {
// read failed
return
}

const prevDescriptor = cacheEntry && cacheEntry.descriptor
if (!prevDescriptor) {
// the file has never been accessed yet
return
}

// check which part of the file changed
if (!isEqual(descriptor.script, prevDescriptor.script)) {
notify({
type: 'reload',
path: servedPath
})
prevStyles.slice(nextStyles.length).forEach((_, i) => {
send({
type: 'style-remove',
path: resourcePath,
id: `${styleId}-${i + nextStyles.length}`
})
return
}

if (!isEqual(descriptor.template, prevDescriptor.template)) {
notify({
type: 'rerender',
path: servedPath
})
} else {
send({
type: 'full-reload'
return
}

const prevStyles = prevDescriptor.styles || []
const nextStyles = descriptor.styles || []
if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
notify({
type: 'reload',
path: servedPath
})
}
})
const styleId = hash_sum(servedPath)
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
notify({
type: 'style-update',
path: servedPath,
index: i,
id: `${styleId}-${i}`
})
}
})
prevStyles.slice(nextStyles.length).forEach((_, i) => {
notify({
type: 'style-remove',
path: servedPath,
id: `${styleId}-${i + nextStyles.length}`
})
})
}
}

function isEqual(a: SFCBlock | null, b: SFCBlock | null) {
Expand Down
56 changes: 52 additions & 4 deletions src/server/plugins/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ export const modulesPlugin: Plugin = ({ root, app }) => {
ctx.body = html.replace(
/(<script\b[^>]*>)([\s\S]*?)<\/script>/gm,
(_, openTag, script) => {
return `${openTag}${rewriteImports(script)}</script>`
return `${openTag}${rewriteImports(script, '/index.html')}</script>`
}
)
}

// we are doing the js rewrite after all other middlewares have finished;
// this allows us to post-process javascript produced any user middlewares
// this allows us to post-process javascript produced by user middlewares
// regardless of the extension of the original files.
if (
ctx.response.is('js') &&
Expand All @@ -41,7 +41,11 @@ export const modulesPlugin: Plugin = ({ root, app }) => {
!(ctx.path.endsWith('.vue') && ctx.query.type != null)
) {
await initLexer
ctx.body = rewriteImports(await readBody(ctx.body))
ctx.body = rewriteImports(
await readBody(ctx.body),
ctx.url.replace(/(&|\?)t=\d+/, ''),
ctx.query.t
)
}
})

Expand Down Expand Up @@ -131,24 +135,68 @@ async function readBody(stream: Readable | string): Promise<string> {
}
}

function rewriteImports(source: string) {
// while we lex the files for imports we also build a import graph
// so that we can determine what files to hot reload
export const importerMap = new Map<string, Set<string>>()
export const importeeMap = new Map<string, Set<string>>()

function rewriteImports(source: string, importer: string, timestamp?: string) {
try {
const [imports] = parse(source)

if (imports.length) {
const s = new MagicString(source)
let hasReplaced = false

const prevImportees = importeeMap.get(importer)
const currentImportees = new Set<string>()
importeeMap.set(importer, currentImportees)

imports.forEach(({ s: start, e: end, d: dynamicIndex }) => {
const id = source.substring(start, end)
if (dynamicIndex < 0) {
if (/^[^\/\.]/.test(id)) {
s.overwrite(start, end, `/__modules/${id}`)
hasReplaced = true
} else if (importer && !id.startsWith(`/__`)) {
// force re-fetch all imports by appending timestamp
// if this is a hmr refresh request
if (timestamp) {
s.overwrite(
start,
end,
`${id}${/\?/.test(id) ? `&` : `?`}t=${timestamp}`
)
hasReplaced = true
}
// save the import chain for hmr analysis
const importee = path.join(path.dirname(importer), id)
currentImportees.add(importee)
let importers = importerMap.get(importee)
if (!importers) {
importers = new Set()
importerMap.set(importee, importers)
}
importers.add(importer)
}
} else {
// TODO dynamic import
}
})

// since the importees may have changed due to edits,
// check if we need to remove this importer from certain importees
if (prevImportees) {
prevImportees.forEach((importee) => {
if (!currentImportees.has(importee)) {
const importers = importerMap.get(importee)
if (importers) {
importers.delete(importer)
}
}
})
}

return hasReplaced ? s.toString() : source
}

Expand Down

0 comments on commit 6e66766

Please sign in to comment.