@taro-hmr-compile/plugin-webpack 0.0.1-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/compile/compile-state.d.ts +3 -0
  2. package/dist/compile/compile-state.js +21 -0
  3. package/dist/compile/compile-state.js.map +1 -0
  4. package/dist/compile/hmr-compiler-loader.d.ts +9 -0
  5. package/dist/compile/hmr-compiler-loader.js +199 -0
  6. package/dist/compile/hmr-compiler-loader.js.map +1 -0
  7. package/dist/compile/hmr-compiler-plugin.d.ts +46 -0
  8. package/dist/compile/hmr-compiler-plugin.js +173 -0
  9. package/dist/compile/hmr-compiler-plugin.js.map +1 -0
  10. package/dist/compile/lazy-compiler-loader.d.ts +18 -0
  11. package/dist/compile/lazy-compiler-loader.js +38 -0
  12. package/dist/compile/lazy-compiler-loader.js.map +1 -0
  13. package/dist/constants.d.ts +4 -0
  14. package/dist/constants.js +8 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.d.ts +9 -0
  17. package/dist/index.js +68 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/log.d.ts +3 -0
  20. package/dist/log.js +11 -0
  21. package/dist/log.js.map +1 -0
  22. package/dist/patch/hmr-patch-plugin.d.ts +17 -0
  23. package/dist/patch/hmr-patch-plugin.js +93 -0
  24. package/dist/patch/hmr-patch-plugin.js.map +1 -0
  25. package/dist/patch/hmr-patch-processor.d.ts +30 -0
  26. package/dist/patch/hmr-patch-processor.js +122 -0
  27. package/dist/patch/hmr-patch-processor.js.map +1 -0
  28. package/dist/patch/hmr-patch-runtime-plugin.d.ts +4 -0
  29. package/dist/patch/hmr-patch-runtime-plugin.js +113 -0
  30. package/dist/patch/hmr-patch-runtime-plugin.js.map +1 -0
  31. package/dist/patch/types.d.ts +16 -0
  32. package/dist/patch/types.js +3 -0
  33. package/dist/patch/types.js.map +1 -0
  34. package/dist/patch/utils.d.ts +28 -0
  35. package/dist/patch/utils.js +179 -0
  36. package/dist/patch/utils.js.map +1 -0
  37. package/dist/server/hmr-server-plugin.d.ts +13 -0
  38. package/dist/server/hmr-server-plugin.js +42 -0
  39. package/dist/server/hmr-server-plugin.js.map +1 -0
  40. package/dist/utils.d.ts +4 -0
  41. package/dist/utils.js +25 -0
  42. package/dist/utils.js.map +1 -0
  43. package/package.json +58 -0
  44. package/src/compile/compile-state.ts +15 -0
  45. package/src/compile/hmr-compiler-loader.ts +177 -0
  46. package/src/compile/hmr-compiler-plugin.ts +233 -0
  47. package/src/compile/lazy-compiler-loader.ts +57 -0
  48. package/src/constants.ts +7 -0
  49. package/src/index.ts +81 -0
  50. package/src/log.ts +5 -0
  51. package/src/patch/hmr-patch-plugin.ts +111 -0
  52. package/src/patch/hmr-patch-processor.ts +169 -0
  53. package/src/patch/hmr-patch-runtime-plugin.ts +119 -0
  54. package/src/patch/types.ts +20 -0
  55. package/src/patch/utils.ts +179 -0
  56. package/src/server/hmr-server-plugin.ts +40 -0
  57. package/src/utils.ts +25 -0
  58. package/tsconfig.json +21 -0
@@ -0,0 +1,233 @@
1
+ import path from 'path'
2
+ import {resolveMainFilePath} from '@tarojs/helper'
3
+ import type {IPaths} from '@tarojs/service'
4
+ import type {AppConfig} from '@tarojs/taro'
5
+ import {SyncHook} from 'tapable'
6
+ import type {Chunk, Compilation, Compiler} from 'webpack'
7
+ import {ConcatSource, RawSource} from 'webpack-sources'
8
+ import type {Source} from 'webpack-sources'
9
+ import {HMR_PATCH_ASSET} from '../constants'
10
+ import {clearCompiledPages, isPageCompiled, markPageCompiled} from './compile-state'
11
+ import {getAllPages} from '../utils'
12
+ import {log} from "../log";
13
+
14
+ const compilerLog = log.extend('HmrCompilerPlugin')
15
+
16
+ const PLUGIN_NAME = 'HmrCompilerPlugin'
17
+
18
+ interface TaroNormalModule {
19
+ miniType?: string
20
+ loaders: Array<{ loader: string; options?: unknown }>
21
+ }
22
+
23
+ type StyleFilename = NonNullable<Compiler['options']['output']['cssFilename']>
24
+
25
+ export interface HmrCompilerPluginOptions {
26
+ paths: IPaths
27
+ appConfig: AppConfig
28
+ styleFilename?: StyleFilename
29
+ debugNamespaces?: string
30
+ }
31
+
32
+ type CompileRequested = SyncHook<[page: string]>
33
+
34
+ interface ChunkModulesRenderedOptions {
35
+ source: string
36
+ isBootstrapChunk: boolean
37
+ chunk: Chunk
38
+ }
39
+
40
+ type ChunkModulesRendered = SyncHook<[options: ChunkModulesRenderedOptions]>
41
+
42
+ export interface HmrCompilerHooks {
43
+ compileRequested: CompileRequested
44
+ }
45
+
46
+ export interface HmrCompilationHooks {
47
+ chunkModulesRendered: ChunkModulesRendered
48
+ }
49
+
50
+ export const applyPatchBeforeStartup = (source: Source, request: string) => {
51
+ const patchRequest = JSON.stringify(`${request}.js`)
52
+ return new ConcatSource(
53
+ `globalThis.miniHmrApplyPatch(require(${patchRequest}));\n`,
54
+ source,
55
+ )
56
+ }
57
+
58
+ export class HmrCompilerPlugin {
59
+ private static readonly compilerHooksMap = new WeakMap<Compiler, HmrCompilerHooks>()
60
+
61
+ private static readonly compilationHooksMap = new WeakMap<Compilation, HmrCompilationHooks>()
62
+
63
+ private compiler?: Compiler
64
+
65
+ private hasCompletedInitialBuild = false
66
+
67
+ // 页面编译请求可能在任意时刻到达。先排队到下一轮 watchRun,避免与正在执行的编译相互覆盖。
68
+ private readonly pendingFiles = new Set<string>()
69
+
70
+ // 当前编译批次的文件快照。与 pendingFiles 分离,确保编译期间新到达的请求留给下一批处理。
71
+ private readonly rebuildingFiles = new Set<string>()
72
+
73
+ private readonly options: HmrCompilerPluginOptions
74
+
75
+ constructor(options: HmrCompilerPluginOptions) {
76
+ this.options = options
77
+ }
78
+
79
+ static getCompilerHooks(compiler: Compiler): HmrCompilerHooks {
80
+ if (!HmrCompilerPlugin.compilerHooksMap.get(compiler)) {
81
+ const compileRequested: CompileRequested = new SyncHook(['page'])
82
+ HmrCompilerPlugin.compilerHooksMap.set(compiler, {compileRequested})
83
+ }
84
+ return HmrCompilerPlugin.compilerHooksMap.get(compiler)!
85
+ }
86
+
87
+ static getCompilationHooks(compilation: Compilation): HmrCompilationHooks {
88
+ if (!HmrCompilerPlugin.compilationHooksMap.get(compilation)) {
89
+ const chunkModulesRendered: ChunkModulesRendered = new SyncHook(['options'])
90
+ HmrCompilerPlugin.compilationHooksMap.set(compilation, {chunkModulesRendered})
91
+ }
92
+ return HmrCompilerPlugin.compilationHooksMap.get(compilation)!
93
+ }
94
+
95
+ apply(compiler: Compiler) {
96
+ this.compiler = compiler
97
+
98
+ HmrCompilerPlugin.getCompilerHooks(compiler).compileRequested.tap(PLUGIN_NAME, page => {
99
+ this.requestCompile(page)
100
+ })
101
+
102
+ compiler.hooks.watchRun.tap(PLUGIN_NAME, currentCompiler => {
103
+ if (this.pendingFiles.size === 0) return
104
+
105
+ // 页面是否使用 Shell 由内存中的 compile-state 决定,源文件本身没有发生变化。
106
+ // 因此需要把请求显式加入 modifiedFiles,并保存当前批次供 needBuild 强制重建。
107
+ this.rebuildingFiles.clear()
108
+ this.pendingFiles.forEach(file => this.rebuildingFiles.add(file))
109
+ currentCompiler.modifiedFiles = new Set([...(currentCompiler.modifiedFiles ?? []), ...this.pendingFiles])
110
+ this.pendingFiles.clear()
111
+ })
112
+
113
+ compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
114
+ const pages = new Set(getAllPages(this.options.appConfig))
115
+ const isPageChunk = (chunk: Chunk) => this.isPageChunk(compilation, pages, chunk)
116
+ const hmrHooks = HmrCompilerPlugin.getCompilationHooks(compilation)
117
+ const javascriptHooks = compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation)
118
+
119
+ const HMR_COMPILER_LOADER = require.resolve('./hmr-compiler-loader')
120
+
121
+ const TARO_LOADER = '@tarojs/taro-loader'
122
+
123
+ // 尽量晚执行
124
+ compiler.webpack.NormalModule.getCompilationHooks(compilation).loader.tap({name: PLUGIN_NAME, stage: 10_000}, (_loaderContext, normalModule: TaroNormalModule) => {
125
+ if (!normalModule.miniType) return
126
+
127
+ if (normalModule.loaders.some(item => item.loader === HMR_COMPILER_LOADER)) return
128
+
129
+ const taroLoaderIndex = normalModule.loaders.findIndex(item => item.loader.replace(/\\/g, '/').includes(TARO_LOADER))
130
+
131
+ if (taroLoaderIndex < 0) return
132
+
133
+ const loaderOptions = {miniType: normalModule.miniType, debugNamespaces: this.options.debugNamespaces}
134
+ // Loader 从右向左执行;插到 taro-loader 左侧才能处理它生成的入口注册代码。
135
+ normalModule.loaders.splice(taroLoaderIndex, 0, {loader: HMR_COMPILER_LOADER, options: loaderOptions})
136
+ },
137
+ )
138
+
139
+ // 只修改 Page Chunk 的 JS transport。Webpack 的 ModuleGraph / ChunkGraph 保持不变,
140
+ // 因而 hash、HMR diff 和 hot-update 仍以完整模块图为准。
141
+ javascriptHooks.renderChunk.tap({name: PLUGIN_NAME, stage: -10_000}, (source, {chunk}) => {
142
+ const isBootstrapChunk = isPageChunk(chunk)
143
+ hmrHooks.chunkModulesRendered.call({source: source.source().toString(), isBootstrapChunk, chunk})
144
+ return isBootstrapChunk ? new RawSource('{}') : source
145
+ })
146
+
147
+ javascriptHooks.renderStartup.tap(PLUGIN_NAME, (source, _module, {chunk}) => {
148
+ if (!isPageChunk(chunk)) return source
149
+
150
+ const filenameTemplate = compiler.webpack.javascript.JavascriptModulesPlugin
151
+ .getChunkFilenameTemplate(chunk, compilation.outputOptions)
152
+ const assetName = compilation.getPath(filenameTemplate, {
153
+ chunk,
154
+ contentHashType: 'javascript',
155
+ hash: compilation.hash,
156
+ })
157
+ const patchModule = HMR_PATCH_ASSET.replace(/\.js$/, '')
158
+ const relativePath = path.posix.relative(path.posix.dirname(assetName), patchModule)
159
+ const request = relativePath.startsWith('.') ? relativePath : `./${relativePath}`
160
+
161
+ // 微信要求 Page() 在当前页面脚本的同步执行阶段完成。bootstrap 会同步补齐工厂,
162
+ // update 继续异步排队,因此这里不能用 Promise.then 包裹 Webpack startup。
163
+ return applyPatchBeforeStartup(source, request)
164
+ })
165
+
166
+ compiler.webpack.NormalModule.getCompilationHooks(compilation).needBuild.tapAsync(
167
+ PLUGIN_NAME,
168
+ (module, _context, callback) => {
169
+ const [resourcePath] = module.resource?.split('?') ?? []
170
+ // 绕过 Webpack 的模块缓存,让 loader 根据最新 compile-state 将页面 Shell 替换为真实页面。
171
+ if (resourcePath && this.rebuildingFiles.has(resourcePath)) return callback(null, true)
172
+ callback()
173
+ },
174
+ )
175
+
176
+ const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT
177
+ compilation.hooks.processAssets.tap({name: PLUGIN_NAME, stage}, () => {
178
+ if (!this.hasCompletedInitialBuild && compilation.hash && compilation.errors.length === 0) {
179
+ this.emitMissingPageStyleAssets(compilation)
180
+ }
181
+ })
182
+ })
183
+
184
+ compiler.hooks.afterEmit.tap(PLUGIN_NAME, compilation => {
185
+ const isSuccessful = compilation.errors.length === 0 && Boolean(compilation.hash)
186
+ this.hasCompletedInitialBuild = this.hasCompletedInitialBuild || isSuccessful
187
+ })
188
+
189
+ // 当前批次完成后释放快照;编译期间新进入 pendingFiles 的请求不会被清理。
190
+ compiler.hooks.done.tap(PLUGIN_NAME, () => this.rebuildingFiles.clear())
191
+
192
+ compiler.hooks.watchClose.tap(PLUGIN_NAME, () => this.reset())
193
+ }
194
+
195
+ private requestCompile(page: string) {
196
+ try {
197
+ if (isPageCompiled(this.options.paths.appPath, page)) return
198
+ // loader 在下一轮构建中读取该状态;必须先标记,再触发 watch invalidation。
199
+ compilerLog('start compile %s', page)
200
+ markPageCompiled(this.options.paths.appPath, page)
201
+ this.pendingFiles.add(resolveMainFilePath(path.resolve(this.options.paths.sourcePath, page)))
202
+ this.compiler?.watching?.invalidate()
203
+ } catch (error) {
204
+ console.error('[taro-hmr-compile] 页面编译触发失败', error)
205
+ }
206
+ }
207
+
208
+ private isPageChunk(compilation: Compilation, pages: Set<string>, chunk: Chunk) {
209
+ if (chunk.name && pages.has(chunk.name)) return true
210
+ return Array.from(pages).some(page => compilation.entrypoints.get(page)?.getEntrypointChunk() === chunk)
211
+ }
212
+
213
+ /** PageShell 不包含真实页面的样式依赖,为缺失的页面样式补充空资产。 */
214
+ private emitMissingPageStyleAssets(compilation: Compilation) {
215
+ const {appConfig, styleFilename} = this.options
216
+ if (!styleFilename) return
217
+
218
+ getAllPages(appConfig).forEach(page => {
219
+ const chunk = compilation.entrypoints.get(page)?.getEntrypointChunk()
220
+ if (!chunk) return
221
+
222
+ const assetName = compilation.getPath(styleFilename, {chunk, contentHashType: 'css/mini-extract'})
223
+ if (!compilation.getAsset(assetName)) compilation.emitAsset(assetName, new RawSource(''))
224
+ })
225
+ }
226
+
227
+ private reset() {
228
+ clearCompiledPages(this.options.paths.appPath)
229
+ this.hasCompletedInitialBuild = false
230
+ this.rebuildingFiles.clear()
231
+ this.pendingFiles.clear()
232
+ }
233
+ }
@@ -0,0 +1,57 @@
1
+ import type {LoaderDefinitionFunction} from 'webpack'
2
+ import path from "path";
3
+ import {getAllPages} from '../utils'
4
+ import {isPageCompiled} from './compile-state'
5
+ import type {AppConfig} from "@tarojs/taro";
6
+
7
+
8
+ interface CreateShellOpt {
9
+ page: string
10
+ framework: 'react'
11
+ compileUrl: string
12
+ memo: boolean
13
+ }
14
+
15
+ export const createReactShell = (opt: CreateShellOpt) => {
16
+ const {page, compileUrl, memo} = opt
17
+ return `
18
+ import { PageShell } from ${JSON.stringify(require.resolve('@taro-hmr-compile/core/runtime'))}
19
+ ${memo ? "import React from 'react'" : ''}
20
+ const Page = () => <PageShell page={${JSON.stringify(page)}} compileUrl={${JSON.stringify(compileUrl)}}/>
21
+ export default ${memo ? 'React.memo(Page)' : 'Page'}
22
+ `
23
+ }
24
+
25
+ const createShell = (opt: CreateShellOpt) => {
26
+ if (opt.framework === 'react') return createReactShell(opt)
27
+ throw new Error(`unsupported framework: ${opt.framework}`)
28
+ }
29
+
30
+ const isMemoPage = (source: string) => {
31
+ return /\bexport\s+default\s+(?:(?:React\.)?memo|observer)\s*\(/.test(source)
32
+ }
33
+
34
+
35
+ interface LoaderOptions {
36
+ appPath: string
37
+ sourcePath: string
38
+ appConfig: AppConfig
39
+ framework: 'react'
40
+ compileUrl: string
41
+ }
42
+
43
+ const lazyCompilerLoader: LoaderDefinitionFunction<LoaderOptions> = function (source) {
44
+ const {appPath, framework, appConfig, sourcePath, compileUrl} = this.getOptions()
45
+
46
+ const {dir: resourceDir, name: resourceName} = path.parse(path.relative(sourcePath, this.resourcePath))
47
+
48
+ const resourcePath = path.join(resourceDir, resourceName)
49
+
50
+ const isPageEntry = getAllPages(appConfig).includes(resourcePath)
51
+
52
+ if (!isPageEntry || isPageCompiled(appPath, resourcePath)) return source
53
+
54
+ return createShell({page: resourcePath, framework, compileUrl, memo: isMemoPage(source)})
55
+ }
56
+
57
+ export default lazyCompilerLoader
@@ -0,0 +1,7 @@
1
+ export const HMR_OUTPUT_PATH = 'hmr'
2
+
3
+ export const HMR_PATCH_ASSET = `${HMR_OUTPUT_PATH}/patch.js`
4
+
5
+ export const DEFAULT_HMR_PATCH_HISTORY_LIMIT = 20
6
+
7
+ export const DEFAULT_DEBUG_NAMESPACES = 'taro-hmr-compile:*'
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type {IPluginContext} from '@tarojs/service'
2
+ import type {AppConfig} from '@tarojs/taro'
3
+ import {HMR_OUTPUT_PATH, DEFAULT_DEBUG_NAMESPACES} from './constants'
4
+ import {getAppConfig} from './utils'
5
+ import {HmrCompilerPlugin} from './compile/hmr-compiler-plugin'
6
+ import {HmrPatchPlugin} from './patch/hmr-patch-plugin'
7
+ import {HmrServerPlugin} from './server/hmr-server-plugin'
8
+ import {emptyDirFile, findAvailablePort} from '@taro-hmr-compile/core/compiler'
9
+ import {enableLog} from './log'
10
+
11
+
12
+ export interface WebpackHmrCompileOpt {
13
+ host: string
14
+ port: number
15
+ patchHistoryLimit: number
16
+ debug: boolean | string
17
+ }
18
+
19
+ export default (ctx: IPluginContext, pluginOpts: Partial<WebpackHmrCompileOpt>) => {
20
+ if (!ctx.runOpts.options?.isWatch) return
21
+
22
+ const {appPath, sourcePath, outputPath} = ctx.paths
23
+
24
+ const {framework} = ctx.initialConfig
25
+
26
+ const appConfig: AppConfig = getAppConfig(ctx)
27
+
28
+ const host = pluginOpts.host ?? '127.0.0.1'
29
+
30
+ const preferredPort = pluginOpts.port ?? 8080
31
+
32
+ const debugNamespaces = (() => {
33
+ if (typeof pluginOpts.debug === 'string') return pluginOpts.debug
34
+ return pluginOpts.debug ? DEFAULT_DEBUG_NAMESPACES : undefined
35
+ })()
36
+
37
+ if (debugNamespaces) enableLog(debugNamespaces)
38
+
39
+ ctx.onBuildStart(() => emptyDirFile(outputPath))
40
+
41
+ ctx.modifyRunnerOpts(({opts}) => {
42
+ opts.output ??= {}
43
+ opts.output.clean = false
44
+ })
45
+
46
+ ctx.modifyWebpackChain(async ({chain, webpack}) => {
47
+ const port = await findAvailablePort(preferredPort, host)
48
+
49
+ const compileUrl = `http://${host}:${port}/compile`
50
+
51
+ if (port !== preferredPort) console.warn(`[taro-hmr-compile] 端口 ${preferredPort} 已被占用,自动使用端口 ${port}`)
52
+
53
+ chain.optimization.usedExports(false)
54
+
55
+ chain.module
56
+ .rule('lazy-compiler')
57
+ .test(/\.[jt]sx?$/)
58
+ .include.add(ctx.paths.sourcePath)
59
+ .end()
60
+ .use('lazy-compiler-loader')
61
+ .loader(require.resolve('./compile/lazy-compiler-loader'))
62
+ .options({appPath, sourcePath, appConfig, framework, compileUrl})
63
+ .end()
64
+
65
+ chain.output
66
+ .set('hotUpdateChunkFilename', `${HMR_OUTPUT_PATH}/[id].[fullhash].hot-update.js`)
67
+ .set('hotUpdateMainFilename', `${HMR_OUTPUT_PATH}/[runtime].[fullhash].hot-update.json`)
68
+
69
+ const {patchHistoryLimit} = pluginOpts
70
+
71
+ const [{filename: styleFilename}] = chain.plugin('miniCssExtractPlugin')?.get('args') || []
72
+
73
+ chain.plugin('hmr-compiler-plugin').use(HmrCompilerPlugin, [{paths: ctx.paths, appConfig, styleFilename, debugNamespaces}])
74
+
75
+ chain.plugin('hmr-plugin').use(webpack.HotModuleReplacementPlugin)
76
+
77
+ chain.plugin('hmr-patch-plugin').use(HmrPatchPlugin, [{patchHistoryLimit}])
78
+
79
+ chain.plugin('hmr-server-plugin').use(HmrServerPlugin, [{host, port}])
80
+ })
81
+ }
package/src/log.ts ADDED
@@ -0,0 +1,5 @@
1
+ import createDebug from 'debug'
2
+
3
+ export const log = createDebug('taro-hmr-compile:webpack')
4
+
5
+ export const enableLog = (namespaces: string) => createDebug.enable(namespaces)
@@ -0,0 +1,111 @@
1
+ import type {Compilation, Compiler} from 'webpack'
2
+ import {RawSource} from 'webpack-sources'
3
+ import {HMR_PATCH_ASSET} from '../constants'
4
+ import {HmrCompilerPlugin} from '../compile/hmr-compiler-plugin'
5
+ import {HmrPatchRuntimePlugin} from './hmr-patch-runtime-plugin'
6
+ import {HmrPatchProcessor, HmrPatchProcessorOptions} from "./hmr-patch-processor";
7
+
8
+ const PLUGIN_NAME = 'HmrPatchPlugin'
9
+ const HOT_UPDATE_MANIFEST_SUFFIX = '.hot-update.json'
10
+ const HOT_UPDATE_SCRIPT_SUFFIX = '.hot-update.js'
11
+ const STYLE_ASSET_PATTERN = /\.(?:css|wxss|acss|ttss|qss|jxss)(?:\.map)?$/i
12
+ const TEMPLATE_RUNTIME_ASSET_PATTERN = /(?:^|\/)(?:base\.wxml|utils\.wxs)$/i
13
+
14
+ type HmrPatchPluginOptions = Partial<HmrPatchProcessorOptions>
15
+
16
+ const isPassthroughAsset = (assetName: string) => {
17
+ return STYLE_ASSET_PATTERN.test(assetName) || TEMPLATE_RUNTIME_ASSET_PATTERN.test(assetName)
18
+ }
19
+
20
+ export class HmrPatchPlugin {
21
+
22
+ private previousHash: string = ''
23
+
24
+ private initialBuildDone = false
25
+
26
+ private readonly patchProcessor: HmrPatchProcessor
27
+
28
+ private readonly emittedAssetSources = new Map<string, string>()
29
+
30
+ constructor(options: HmrPatchPluginOptions) {
31
+ this.patchProcessor = new HmrPatchProcessor(options)
32
+ }
33
+
34
+ apply(compiler: Compiler) {
35
+ new HmrPatchRuntimePlugin().apply(compiler)
36
+
37
+ compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
38
+ HmrCompilerPlugin.getCompilationHooks(compilation).chunkModulesRendered.tap(PLUGIN_NAME, options => {
39
+ this.patchProcessor.processChunkModules({compilation, ...options})
40
+ })
41
+
42
+ // 等 Webpack 生成完整的 hot-update assets 后,再统一转换成固定的 patch.js。
43
+ const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT
44
+ compilation.hooks.processAssets.tap({name: PLUGIN_NAME, stage}, () => this.processAssets(compilation))
45
+ })
46
+
47
+ compiler.hooks.afterEmit.tap(PLUGIN_NAME, compilation => {
48
+ const isSuccessful = compilation.errors.length === 0 && Boolean(compilation.hash)
49
+ this.initialBuildDone = this.initialBuildDone || isSuccessful
50
+ })
51
+
52
+ compiler.hooks.watchClose.tap(PLUGIN_NAME, () => this.reset())
53
+ }
54
+
55
+ private processAssets(compilation: Compilation) {
56
+ const {previousHash: fromHash, initialBuildDone} = this
57
+
58
+ const {hash: toHash, errors: compilationErrors} = compilation
59
+
60
+ if (!toHash || compilationErrors.length > 0) return
61
+ // 首次编译完成过滤文件输出
62
+ if (!initialBuildDone) {
63
+ this.patchProcessor.commitBootstrapFactories(compilation)
64
+ compilation.getAssets().forEach(asset => {
65
+ if (!isPassthroughAsset(asset.name)) return
66
+ this.emittedAssetSources.set(asset.name, asset.source.source().toString())
67
+ })
68
+ this.previousHash = toHash
69
+ return this.emitPatchAsset(compilation, false)
70
+ }
71
+
72
+ const manifests = compilation.getAssets().filter(asset => asset.name.endsWith(HOT_UPDATE_MANIFEST_SUFFIX))
73
+
74
+ const scripts = compilation.getAssets().filter(asset => asset.name.endsWith(HOT_UPDATE_SCRIPT_SUFFIX))
75
+
76
+ this.patchProcessor.processUpdate({toHash, fromHash, manifests, scripts})
77
+
78
+ this.emitPatchAsset(compilation, true)
79
+
80
+ this.removeSupersededAssets(compilation)
81
+
82
+ this.previousHash = toHash
83
+ }
84
+
85
+ /** 热更新转换完成后,保留固定 patch 与发生变化的模板、平台样式资产。 */
86
+ private removeSupersededAssets(compilation: Compilation) {
87
+ compilation.getAssets().forEach(asset => {
88
+ if (asset.name === HMR_PATCH_ASSET) return
89
+ if (!isPassthroughAsset(asset.name)) return compilation.deleteAsset(asset.name)
90
+
91
+ const source = asset.source.source().toString()
92
+ const previousSource = this.emittedAssetSources.get(asset.name)
93
+ this.emittedAssetSources.set(asset.name, source)
94
+ if (previousSource === source) compilation.deleteAsset(asset.name)
95
+ })
96
+ }
97
+
98
+ private emitPatchAsset(compilation: Compilation, isHotUpdate: boolean) {
99
+ const source = new RawSource(this.patchProcessor.generate())
100
+ const assetInfo = {hotModuleReplacement: isHotUpdate}
101
+ if (compilation.getAsset(HMR_PATCH_ASSET)) compilation.updateAsset(HMR_PATCH_ASSET, source, assetInfo)
102
+ else compilation.emitAsset(HMR_PATCH_ASSET, source, assetInfo)
103
+ }
104
+
105
+ private reset() {
106
+ this.previousHash = ''
107
+ this.initialBuildDone = false
108
+ this.emittedAssetSources.clear()
109
+ this.patchProcessor.reset()
110
+ }
111
+ }
@@ -0,0 +1,169 @@
1
+ import {type Chunk, type Compilation,} from "webpack";
2
+ import {
3
+ parseHotUpdateAssets,
4
+ ParseHotUpdateAssetsOptions,
5
+ parseChunkAsset, mergeUpdatePatches, generatePatchAssetSource,
6
+ } from "./utils";
7
+ import isNil from 'lodash/isNil'
8
+ import {ModuleFactory, UpdatePatchSource} from "./types";
9
+ import {DEFAULT_HMR_PATCH_HISTORY_LIMIT} from "../constants";
10
+
11
+ interface ProcessChunkModulesOptions {
12
+ source: string
13
+ compilation: Compilation,
14
+ isBootstrapChunk: boolean,
15
+ chunk: Chunk
16
+ }
17
+
18
+ export interface HmrPatchProcessorOptions {
19
+ patchHistoryLimit?: number
20
+ }
21
+
22
+ interface ProcessUpdateOptions extends ParseHotUpdateAssetsOptions {
23
+ fromHash: string
24
+ toHash: string
25
+ }
26
+
27
+ type BootstrapFactories = Map<string, ModuleFactory>
28
+
29
+ type ModuleHashes = Map<string, Set<string>>
30
+
31
+ type ModuleHashesByFullHash = Map<string, ModuleHashes>
32
+
33
+ const areModuleHashesEqual = (left: Set<string> | undefined, right: Set<string> | undefined): boolean => {
34
+ if (!left || !right || left.size !== right.size) return false
35
+ return Array.from(left).every(hash => right.has(hash))
36
+ }
37
+
38
+ export class HmrPatchProcessor {
39
+ private readonly bootstrapFactories: BootstrapFactories = new Map()
40
+
41
+ private readonly bootstrapFactoriesByCompilation = new WeakMap<Compilation, BootstrapFactories>()
42
+
43
+ private readonly moduleHashesByFullHash: ModuleHashesByFullHash = new Map()
44
+
45
+ private recentUpdatePatches: UpdatePatchSource[] = []
46
+
47
+ private readonly opts: Partial<HmrPatchProcessorOptions>
48
+
49
+ constructor(opts: Partial<HmrPatchProcessorOptions>) {
50
+ this.opts = opts
51
+ }
52
+
53
+ private getChangedModuleIdsByFullHash(fromHash: string, toHash: string): Set<string> | undefined {
54
+ const fromModuleHashes = this.moduleHashesByFullHash.get(fromHash)
55
+
56
+ const toModuleHashes = this.moduleHashesByFullHash.get(toHash)
57
+
58
+ // 任一编译缺少 Hash 基线时不做过滤,避免误丢有效的 hot-update 模块。
59
+ if (!fromModuleHashes || !toModuleHashes) return
60
+
61
+ // 同时检查前后两次编译的模块,确保新增、修改和删除都能被识别。
62
+ const moduleIds = new Set(fromModuleHashes.keys())
63
+ toModuleHashes.forEach((_, moduleId) => moduleIds.add(moduleId))
64
+
65
+ const changedModuleIds = new Set<string>()
66
+ moduleIds.forEach(moduleId => {
67
+ const fromModuleHash = fromModuleHashes.get(moduleId)
68
+ const toModuleHash = toModuleHashes.get(moduleId)
69
+ if (!areModuleHashesEqual(fromModuleHash, toModuleHash)) changedModuleIds.add(moduleId)
70
+ })
71
+
72
+ return changedModuleIds
73
+ }
74
+
75
+ processUpdate(opt: ProcessUpdateOptions) {
76
+ const {fromHash, toHash} = opt
77
+
78
+ const tempPatchInfo = parseHotUpdateAssets(opt)
79
+
80
+ const changedModuleIds = this.getChangedModuleIdsByFullHash(fromHash, toHash)
81
+
82
+ const moduleFactories = (() => {
83
+ if (!changedModuleIds) return Array.from(tempPatchInfo.moduleFactories.values())
84
+ return Array.from(tempPatchInfo.moduleFactories.values()).filter(item => changedModuleIds.has(item.moduleId))
85
+ })()
86
+
87
+ const removedModules = (() => {
88
+ if (!changedModuleIds) return Array.from(tempPatchInfo.removedModules.values())
89
+ return Array.from(tempPatchInfo.removedModules.values()).filter(item => changedModuleIds.has(item))
90
+ })()
91
+
92
+ const {runtimeModules} = tempPatchInfo
93
+
94
+ this.recentUpdatePatches.push({type: 'update', moduleFactories, runtimeModules, fromHash, toHash, removedModules})
95
+
96
+ const patchHistoryLimit = Math.max(1, this.opts.patchHistoryLimit ?? DEFAULT_HMR_PATCH_HISTORY_LIMIT)
97
+
98
+ if (this.recentUpdatePatches.length <= patchHistoryLimit) return
99
+
100
+ const [oldestUpdatePatch, secondOldestUpdatePatch, ...rest] = this.recentUpdatePatches
101
+
102
+ const mergedUpdatePatch = mergeUpdatePatches(oldestUpdatePatch, secondOldestUpdatePatch)
103
+
104
+ this.recentUpdatePatches = [mergedUpdatePatch, ...rest]
105
+
106
+ this.moduleHashesByFullHash.delete(oldestUpdatePatch.toHash)
107
+ }
108
+
109
+ processChunkModules(opt: ProcessChunkModulesOptions) {
110
+ const {chunk, compilation, isBootstrapChunk, source} = opt
111
+ const {hash: fullHash} = compilation
112
+ const modules = parseChunkAsset(source)
113
+ modules.forEach(moduleFactory => {
114
+ const {moduleId, factory} = moduleFactory
115
+
116
+ if (isBootstrapChunk) {
117
+ const compilationFactories = this.bootstrapFactoriesByCompilation.get(compilation) ?? new Map()
118
+ compilationFactories.set(moduleId, {moduleId, factory})
119
+ this.bootstrapFactoriesByCompilation.set(compilation, compilationFactories)
120
+ }
121
+ })
122
+
123
+ if (!fullHash) return
124
+
125
+ const moduleHashes = (() => {
126
+ const hasCurFullHashModuleHashMap = this.moduleHashesByFullHash.has(fullHash)
127
+ if (!hasCurFullHashModuleHashMap) this.moduleHashesByFullHash.set(fullHash, new Map())
128
+ return this.moduleHashesByFullHash.get(fullHash)!
129
+ })()
130
+
131
+ const { chunkGraph, codeGenerationResults } = compilation
132
+
133
+ Array.from(chunkGraph.getChunkModulesIterable(chunk)).forEach(module => {
134
+ const moduleId = chunkGraph.getModuleId(module)
135
+
136
+ if (isNil(moduleId)) return
137
+
138
+ const normalizedModuleId = String(moduleId)
139
+
140
+ // 获取模块在当前 chunk.runtime 下的 Hash,优先从 codeGenerationResults 获取,如果没有对应的代码生成结果,则回退到 chunkGraph 获取
141
+ const moduleHash = codeGenerationResults?.has(module, chunk.runtime)
142
+ ? codeGenerationResults.getHash(module, chunk.runtime)
143
+ : chunkGraph.getModuleHash(module, chunk.runtime)
144
+
145
+ const hashes = moduleHashes.get(normalizedModuleId) ?? new Set<string>()
146
+ hashes.add(moduleHash)
147
+ moduleHashes.set(normalizedModuleId, hashes)
148
+ })
149
+ }
150
+
151
+ commitBootstrapFactories(compilation: Compilation) {
152
+ this.bootstrapFactories.clear()
153
+ this.bootstrapFactoriesByCompilation.get(compilation)?.forEach(moduleFactory => {
154
+ this.bootstrapFactories.set(moduleFactory.moduleId, moduleFactory)
155
+ })
156
+ this.bootstrapFactoriesByCompilation.delete(compilation)
157
+ }
158
+
159
+ generate() {
160
+ const {bootstrapFactories, recentUpdatePatches} = this
161
+ return generatePatchAssetSource({bootstrapFactories, recentUpdatePatches})
162
+ }
163
+
164
+ reset() {
165
+ this.bootstrapFactories.clear()
166
+ this.moduleHashesByFullHash.clear()
167
+ this.recentUpdatePatches = []
168
+ }
169
+ }