diff --git a/package.json b/package.json index b648a6fa46c981..5d03d8495a1702 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/client.ts b/src/client/client.ts index 13bfd035f6e5fc..8021f4738f025a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -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() } @@ -66,12 +77,22 @@ export function updateStyle(id: string, url: string) { link.setAttribute('href', url) } +const jsUpdateMap = new Map 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) + } + }) } } diff --git a/src/server/plugins/hmr.ts b/src/server/plugins/hmr.ts index d1b170a0f6e914..21cd6dbba75429 100644 --- a/src/server/plugins/hmr.ts +++ b/src/server/plugins/hmr.ts @@ -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' @@ -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 @@ -56,21 +87,23 @@ 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() const jsHotImporters = new Set() const hasDeadEnd = walkImportChain( + servedPath, importers, vueImporters, jsHotImporters @@ -78,24 +111,30 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => { 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, vueImporters: Set, jsHotImporters: Set @@ -104,7 +143,7 @@ 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) @@ -112,6 +151,7 @@ export const hmrPlugin: Plugin = ({ root, app, server }) => { hasDeadEnd = true } else { hasDeadEnd = walkImportChain( + importer, parentImpoters, vueImporters, jsHotImporters @@ -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) @@ -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 } @@ -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 }) }) } diff --git a/src/server/plugins/modules.ts b/src/server/plugins/modules.ts index 3c01b1e6a0b4f8..1356ff76f6b123 100644 --- a/src/server/plugins/modules.ts +++ b/src/server/plugins/modules.ts @@ -3,11 +3,13 @@ import { resolveVue } from '../resolveVue' import path from 'path' import resolve from 'resolve-from' import { Readable } from 'stream' -import { init as initLexer, parse } from 'es-module-lexer' +import { init as initLexer, parse as parseImports } from 'es-module-lexer' import MagicString from 'magic-string' import { cachedRead } from '../utils' import { promises as fs } from 'fs' import { hmrClientPublicPath } from './hmr' +import { parse } from '@babel/parser' +import { StringLiteral } from '@babel/types' const idToFileMap = new Map() const fileToIdMap = new Map() @@ -174,13 +176,24 @@ async function resolveWebModule( // 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>() -export const importeeMap = new Map>() -export const hotBoundariesMap = new Map>() +type HMRStateMap = Map> + +export const importerMap: HMRStateMap = new Map() +export const importeeMap: HMRStateMap = new Map() +export const hmrBoundariesMap: HMRStateMap = new Map() + +const ensureMapEntry = (map: HMRStateMap, key: string): Set => { + let entry = map.get(key) + if (!entry) { + entry = new Set() + map.set(key, entry) + } + return entry +} function rewriteImports(source: string, importer: string, timestamp?: string) { try { - const [imports] = parse(source) + const [imports] = parseImports(source) if (imports.length) { const s = new MagicString(source) @@ -197,6 +210,13 @@ function rewriteImports(source: string, importer: string, timestamp?: string) { s.overwrite(start, end, `/@modules/${id}`) hasReplaced = true } else if (id === hmrClientPublicPath) { + if (!/.vue$|.vue\?type=/.test(importer)) { + // the user explicit imports the HMR API in a js file + // making the module hot. + parseAcceptedDeps(source, importer, s) + // we rewrite the hot.accept call + hasReplaced = true + } } else { // force re-fetch all imports by appending timestamp // if this is a hmr refresh request @@ -211,12 +231,7 @@ function rewriteImports(source: string, importer: string, timestamp?: string) { // 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) + ensureMapEntry(importerMap, importee).add(importer) } } else if (dynamicIndex >= 0) { // TODO dynamic import @@ -241,8 +256,65 @@ function rewriteImports(source: string, importer: string, timestamp?: string) { return source } catch (e) { - debugger - console.error(`Error: module imports rewrite failed for source:\n`, source) + console.error( + `[vite] Error: module imports rewrite failed for ${importer}.`, + e + ) return source } } + +function parseAcceptedDeps(source: string, importer: string, s: MagicString) { + const ast = parse(source, { + sourceType: 'module', + plugins: [ + // by default we enable proposals slated for ES2020. + // full list at https://babeljs.io/docs/en/next/babel-parser#plugins + // this should be kept in async with @vue/compiler-core's support range + 'bigInt', + 'optionalChaining', + 'nullishCoalescingOperator' + ] + }).program.body + + const registerDep = (e: StringLiteral) => { + const deps = ensureMapEntry(hmrBoundariesMap, importer) + const depPublicPath = path.join(path.dirname(importer), e.value) + deps.add(depPublicPath) + s.overwrite(e.start!, e.end!, JSON.stringify(depPublicPath)) + } + + ast.forEach((node) => { + if ( + node.type === 'ExpressionStatement' && + node.expression.type === 'CallExpression' && + node.expression.callee.type === 'MemberExpression' && + node.expression.callee.object.type === 'Identifier' && + node.expression.callee.object.name === 'hot' && + node.expression.callee.property.name === 'accept' + ) { + const args = node.expression.arguments + // inject the imports's own path so it becomes + // hot.accept('/foo.js', ['./bar.js'], () => {}) + s.appendLeft(args[0].start!, JSON.stringify(importer) + ', ') + // register the accepted deps + if (args[0].type === 'ArrayExpression') { + args[0].elements.forEach((e) => { + if (e && e.type !== 'StringLiteral') { + console.error( + `[vite] HMR syntax error in ${importer}: hot.accept() deps list can only contain string literals.` + ) + } else if (e) { + registerDep(e) + } + }) + } else if (args[0].type === 'StringLiteral') { + registerDep(args[0]) + } else { + console.error( + `[vite] HMR syntax error in ${importer}: hot.accept() expects a dep string or an array of deps.` + ) + } + } + }) +} diff --git a/yarn.lock b/yarn.lock index 97efaa29e44b98..8a1f62564c7dc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -144,7 +144,7 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0": +"@babel/parser@^7.1.0", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0", "@babel/parser@^7.9.4": version "7.9.4" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.9.4.tgz#68a35e6b0319bbc014465be43828300113f2f2e8" integrity sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==