Skip to content

Commit

Permalink
feat: js hmr API
Browse files Browse the repository at this point in the history
  • Loading branch information
yyx990803 committed Apr 23, 2020
1 parent ae3c83a commit 3e5076d
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 50 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
]
},
"dependencies": {
"@babel/parser": "^7.9.4",
"@types/koa": "^2.11.3",
"@vue/compiler-sfc": "^3.0.0-beta.3",
"chokidar": "^3.3.1",
Expand Down
45 changes: 33 additions & 12 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,46 @@ const socket = new WebSocket(`ws://${location.host}`)

// Listen for messages
socket.addEventListener('message', ({ data }) => {
const { type, path, id, index } = JSON.parse(data)
const { type, path, id, index, timestamp } = JSON.parse(data)
switch (type) {
case 'connected':
console.log(`[vite] connected.`)
break
case 'reload':
import(`${path}?t=${Date.now()}`).then((m) => {
case 'vue-reload':
import(`${path}?t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.reload(path, m.default)
console.log(`[vite] ${path} reloaded.`)
})
break
case 'rerender':
import(`${path}?type=template&t=${Date.now()}`).then((m) => {
case 'vue-rerender':
import(`${path}?type=template&t=${timestamp}`).then((m) => {
__VUE_HMR_RUNTIME__.rerender(path, m.render)
console.log(`[vite] ${path} template updated.`)
})
break
case 'style-update':
case 'vue-style-update':
updateStyle(id, `${path}?type=style&index=${index}&t=${timestamp}`)
console.log(
`[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.`
)
updateStyle(id, `${path}?type=style&index=${index}&t=${Date.now()}`)
break
case 'style-remove':
case 'vue-style-remove':
const link = document.getElementById(`vite-css-${id}`)
if (link) {
document.head.removeChild(link)
}
break
case 'js-update':
const update = jsUpdateMap.get(path)
if (update) {
update(timestamp)
console.log(`[vite]: js module reloaded: `, path)
} else {
console.error(
`[vite] got js update notification but no client callback was registered. Something is wrong.`
)
}
break
case 'full-reload':
location.reload()
}
Expand Down Expand Up @@ -66,12 +77,22 @@ export function updateStyle(id: string, url: string) {
link.setAttribute('href', url)
}

const jsUpdateMap = new Map<string, (timestamp: number) => void>()

export const hot = {
accept(
boundaryUrl: string,
deps: string[],
callback: (modules: object[]) => void
importer: string,
deps: string | string[],
callback: (modules: object | object[]) => void
) {
// TODO
jsUpdateMap.set(importer, (timestamp: number) => {
if (Array.isArray(deps)) {
Promise.all(deps.map((dep) => import(dep + `?t=${timestamp}`))).then(
callback
)
} else {
import(deps + `?t=${timestamp}`).then(callback)
}
})
}
}
97 changes: 73 additions & 24 deletions src/server/plugins/hmr.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
// How HMR works
// 1. `.vue` files are transformed into `.js` files before being served
// 2. All `.js` files, before being served, are parsed to detect their imports
// (this is done in `./modules.ts`) for module import rewriting. During this
// we also record the importer/importee relationships which can beused for
// HMR analysis (we do both at the same time to avoid double parse costs)
// 3. When a `.vue` file changes, we directly read, parse it again and
// notify the client because Vue components are self-accepting by nature
// 4. When a js file changes, it triggers an HMR graph analysis, where we try to
// walk its importer chains and see if we reach a "HMR boundary". An HMR
// boundary is either a `.vue` file or a `.js` file that explicitly indicated
// that it accepts hot updates (by importing from the `/@hmr` special module)
// 5. If any parent chain exhausts without ever running into an HMR boundary,
// it's considered a "dead end". This causes a full page reload.
// 6. If a `.vue` boundary is encountered, we add it to the `vueImports` Set.
// 7. If a `.js` boundary is encountered, we check if the boundary's current
// child importer is in the accepted list of the boundary (see additional
// explanation below). If yes, record current child importer in the
// `jsImporters` Set.
// 8. If the graph walk finished without running into dead ends, notify the
// client to update all `jsImporters` and `vueImporters`.

// How do we get a js HMR boundary's accepted list on the server
// 1. During the import rewriting, if `/@hmr` import is present in a js file,
// we will do a fullblown parse of the file to find the `hot.accept` call,
// and records the file and its accepted dependencies in a `hmrBoundariesMap`
// 2. We also inject the boundary file's full path into the `hot.accept` call
// so that on the client, the `hot.accept` call would have reigstered for
// updates using the full paths of the dependencies.

import { Plugin } from '../index'
import path from 'path'
import WebSocket from 'ws'
Expand All @@ -6,13 +36,14 @@ import chokidar from 'chokidar'
import { SFCBlock } from '@vue/compiler-sfc'
import { parseSFC, vueCache } from './vue'
import { cachedRead } from '../utils'
import { importerMap } from './modules'
import { importerMap, hmrBoundariesMap } from './modules'

const hmrClientFilePath = path.resolve(__dirname, '../../client/client.js')
export const hmrClientPublicPath = '/@hmr'

interface HMRPayload {
type: string
timestamp: number
path?: string
id?: string
index?: number
Expand Down Expand Up @@ -56,46 +87,54 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
})

watcher.on('change', async (file) => {
const timestamp = Date.now()
const servedPath = '/' + path.relative(root, file)
if (file.endsWith('.vue')) {
handleVueSFCReload(file, servedPath)
handleVueSFCReload(file, servedPath, timestamp)
} else {
handleJSReload(servedPath)
handleJSReload(servedPath, timestamp)
}
})

function handleJSReload(servedPath: string) {
function handleJSReload(servedPath: string, timestamp: number) {
// normal js file
const importers = importerMap.get(servedPath)
if (importers) {
const vueImporters = new Set<string>()
const jsHotImporters = new Set<string>()
const hasDeadEnd = walkImportChain(
servedPath,
importers,
vueImporters,
jsHotImporters
)

if (hasDeadEnd) {
notify({
type: 'full-reload'
type: 'full-reload',
timestamp
})
} else {
vueImporters.forEach((vueImporter) => {
notify({
type: 'reload',
path: vueImporter
type: 'vue-reload',
path: vueImporter,
timestamp
})
})
jsHotImporters.forEach((jsImporter) => {
// TODO
console.log(jsImporter)
notify({
type: 'js-update',
path: jsImporter,
timestamp
})
})
}
}
}

function walkImportChain(
importee: string,
currentImporters: Set<string>,
vueImporters: Set<string>,
jsHotImporters: Set<string>
Expand All @@ -104,14 +143,15 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
for (const importer of currentImporters) {
if (importer.endsWith('.vue')) {
vueImporters.add(importer)
} else if (isHotBoundary(importer)) {
} else if (isHMRBoundary(importer, importee)) {
jsHotImporters.add(importer)
} else {
const parentImpoters = importerMap.get(importer)
if (!parentImpoters) {
hasDeadEnd = true
} else {
hasDeadEnd = walkImportChain(
importer,
parentImpoters,
vueImporters,
jsHotImporters
Expand All @@ -122,12 +162,16 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
return hasDeadEnd
}

function isHotBoundary(servedPath: string): boolean {
// TODO
return true
function isHMRBoundary(importer: string, dep: string): boolean {
const deps = hmrBoundariesMap.get(importer)
return deps ? deps.has(dep) : false
}

async function handleVueSFCReload(file: string, servedPath: string) {
async function handleVueSFCReload(
file: string,
servedPath: string,
timestamp: number
) {
const cacheEntry = vueCache.get(file)
vueCache.del(file)

Expand All @@ -146,16 +190,18 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
// check which part of the file changed
if (!isEqual(descriptor.script, prevDescriptor.script)) {
notify({
type: 'reload',
path: servedPath
type: 'vue-reload',
path: servedPath,
timestamp
})
return
}

if (!isEqual(descriptor.template, prevDescriptor.template)) {
notify({
type: 'rerender',
path: servedPath
type: 'vue-rerender',
path: servedPath,
timestamp
})
return
}
Expand All @@ -164,26 +210,29 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => {
const nextStyles = descriptor.styles || []
if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
notify({
type: 'reload',
path: servedPath
type: 'vue-reload',
path: servedPath,
timestamp
})
}
const styleId = hash_sum(servedPath)
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
notify({
type: 'style-update',
type: 'vue-style-update',
path: servedPath,
index: i,
id: `${styleId}-${i}`
id: `${styleId}-${i}`,
timestamp
})
}
})
prevStyles.slice(nextStyles.length).forEach((_, i) => {
notify({
type: 'style-remove',
type: 'vue-style-remove',
path: servedPath,
id: `${styleId}-${i + nextStyles.length}`
id: `${styleId}-${i + nextStyles.length}`,
timestamp
})
})
}
Expand Down
Loading

0 comments on commit 3e5076d

Please sign in to comment.