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

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 (46) 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-compile-loader.d.ts +11 -0
  5. package/dist/compile/hmr-compile-loader.js +52 -0
  6. package/dist/compile/hmr-compile-loader.js.map +1 -0
  7. package/dist/compile/hmr-compiler-plugin.d.ts +38 -0
  8. package/dist/compile/hmr-compiler-plugin.js +158 -0
  9. package/dist/compile/hmr-compiler-plugin.js.map +1 -0
  10. package/dist/compile/inject-app-hmr-runtime.d.ts +1 -0
  11. package/dist/compile/inject-app-hmr-runtime.js +46 -0
  12. package/dist/compile/inject-app-hmr-runtime.js.map +1 -0
  13. package/dist/constants.d.ts +3 -0
  14. package/dist/constants.js +7 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.d.ts +8 -0
  17. package/dist/index.js +49 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/patch/hmr-patch-manager.d.ts +30 -0
  20. package/dist/patch/hmr-patch-manager.js +75 -0
  21. package/dist/patch/hmr-patch-manager.js.map +1 -0
  22. package/dist/patch/hmr-patch-plugin.d.ts +16 -0
  23. package/dist/patch/hmr-patch-plugin.js +239 -0
  24. package/dist/patch/hmr-patch-plugin.js.map +1 -0
  25. package/dist/patch/hmr-patch-runtime-plugin.d.ts +4 -0
  26. package/dist/patch/hmr-patch-runtime-plugin.js +142 -0
  27. package/dist/patch/hmr-patch-runtime-plugin.js.map +1 -0
  28. package/dist/server/hmr-server-plugin.d.ts +13 -0
  29. package/dist/server/hmr-server-plugin.js +29 -0
  30. package/dist/server/hmr-server-plugin.js.map +1 -0
  31. package/dist/utils.d.ts +4 -0
  32. package/dist/utils.js +25 -0
  33. package/dist/utils.js.map +1 -0
  34. package/package.json +51 -0
  35. package/src/compile/compile-state.ts +15 -0
  36. package/src/compile/hmr-compile-loader.ts +72 -0
  37. package/src/compile/hmr-compiler-plugin.ts +191 -0
  38. package/src/compile/inject-app-hmr-runtime.ts +44 -0
  39. package/src/constants.ts +5 -0
  40. package/src/index.ts +67 -0
  41. package/src/patch/hmr-patch-manager.ts +102 -0
  42. package/src/patch/hmr-patch-plugin.ts +237 -0
  43. package/src/patch/hmr-patch-runtime-plugin.ts +150 -0
  44. package/src/server/hmr-server-plugin.ts +34 -0
  45. package/src/utils.ts +25 -0
  46. package/tsconfig.json +21 -0
@@ -0,0 +1,191 @@
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
+
13
+ const PLUGIN_NAME = 'HmrCompilerPlugin'
14
+
15
+ type StyleFilename = NonNullable<Compiler['options']['output']['cssFilename']>
16
+
17
+ export interface HmrCompilerPluginOptions {
18
+ paths: IPaths
19
+ appConfig: AppConfig
20
+ styleFilename?: StyleFilename
21
+ }
22
+
23
+ export interface HmrCompilerHooks {
24
+ compileRequested: SyncHook<[page: string]>
25
+ }
26
+
27
+ export interface HmrCompilationHooks {
28
+ pageChunkModules: SyncHook<[source: string]>
29
+ }
30
+
31
+ export const applyPatchBeforeStartup = (source: Source, request: string) => {
32
+ const patchRequest = JSON.stringify(`${request}.js`)
33
+ return new ConcatSource(
34
+ `globalThis.miniHmrApplyPatch(require(${patchRequest}));\n`,
35
+ source,
36
+ )
37
+ }
38
+
39
+ export class HmrCompilerPlugin {
40
+ private static readonly compilerHooksMap = new WeakMap<Compiler, HmrCompilerHooks>()
41
+
42
+ private static readonly compilationHooksMap = new WeakMap<Compilation, HmrCompilationHooks>()
43
+
44
+ private compiler?: Compiler
45
+
46
+ private hasCompletedInitialBuild = false
47
+
48
+ // 页面编译请求可能在任意时刻到达。先排队到下一轮 watchRun,避免与正在执行的编译相互覆盖。
49
+ private readonly pendingFiles = new Set<string>()
50
+
51
+ // 当前编译批次的文件快照。与 pendingFiles 分离,确保编译期间新到达的请求留给下一批处理。
52
+ private readonly rebuildingFiles = new Set<string>()
53
+
54
+ constructor(private readonly options: HmrCompilerPluginOptions) {}
55
+
56
+ static getCompilerHooks(compiler: Compiler): HmrCompilerHooks {
57
+ let hooks = HmrCompilerPlugin.compilerHooksMap.get(compiler)
58
+ if (!hooks) {
59
+ hooks = {compileRequested: new SyncHook(['page'])}
60
+ HmrCompilerPlugin.compilerHooksMap.set(compiler, hooks)
61
+ }
62
+ return hooks
63
+ }
64
+
65
+ static getCompilationHooks(compilation: Compilation): HmrCompilationHooks {
66
+ let hooks = HmrCompilerPlugin.compilationHooksMap.get(compilation)
67
+ if (!hooks) {
68
+ hooks = {pageChunkModules: new SyncHook(['source'])}
69
+ HmrCompilerPlugin.compilationHooksMap.set(compilation, hooks)
70
+ }
71
+ return hooks
72
+ }
73
+
74
+ apply(compiler: Compiler) {
75
+ this.compiler = compiler
76
+
77
+ HmrCompilerPlugin.getCompilerHooks(compiler).compileRequested.tap(PLUGIN_NAME, page => {
78
+ this.requestCompile(page)
79
+ })
80
+
81
+ compiler.hooks.watchRun.tap(PLUGIN_NAME, currentCompiler => {
82
+ if (this.pendingFiles.size === 0) return
83
+
84
+ // 页面是否使用 Shell 由内存中的 compile-state 决定,源文件本身没有发生变化。
85
+ // 因此需要把请求显式加入 modifiedFiles,并保存当前批次供 needBuild 强制重建。
86
+ this.rebuildingFiles.clear()
87
+ this.pendingFiles.forEach(file => this.rebuildingFiles.add(file))
88
+ currentCompiler.modifiedFiles = new Set([...(currentCompiler.modifiedFiles ?? []), ...this.pendingFiles])
89
+ this.pendingFiles.clear()
90
+ })
91
+
92
+ compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
93
+ const pages = new Set(getAllPages(this.options.appConfig))
94
+ const isPageChunk = (chunk: Chunk) => this.isPageChunk(compilation, pages, chunk)
95
+ const hmrHooks = HmrCompilerPlugin.getCompilationHooks(compilation)
96
+ const javascriptHooks = compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation)
97
+
98
+ // 只修改 Page Chunk 的 JS transport。Webpack 的 ModuleGraph / ChunkGraph 保持不变,
99
+ // 因而 hash、HMR diff 和 hot-update 仍以完整模块图为准。
100
+ javascriptHooks.renderChunk.tap({name: PLUGIN_NAME, stage: -10_000}, (source, {chunk}) => {
101
+ if (!isPageChunk(chunk)) return source
102
+ hmrHooks.pageChunkModules.call(source.source().toString())
103
+ return new RawSource('{}')
104
+ })
105
+
106
+ javascriptHooks.renderStartup.tap(PLUGIN_NAME, (source, _module, {chunk}) => {
107
+ if (!isPageChunk(chunk)) return source
108
+
109
+ const filenameTemplate = compiler.webpack.javascript.JavascriptModulesPlugin
110
+ .getChunkFilenameTemplate(chunk, compilation.outputOptions)
111
+ const assetName = compilation.getPath(filenameTemplate, {
112
+ chunk,
113
+ contentHashType: 'javascript',
114
+ hash: compilation.hash,
115
+ })
116
+ const patchModule = HMR_PATCH_ASSET.replace(/\.js$/, '')
117
+ const relativePath = path.posix.relative(path.posix.dirname(assetName), patchModule)
118
+ const request = relativePath.startsWith('.') ? relativePath : `./${relativePath}`
119
+
120
+ // 微信要求 Page() 在当前页面脚本的同步执行阶段完成。bootstrap 会同步补齐工厂,
121
+ // update 继续异步排队,因此这里不能用 Promise.then 包裹 Webpack startup。
122
+ return applyPatchBeforeStartup(source, request)
123
+ })
124
+
125
+ compiler.webpack.NormalModule.getCompilationHooks(compilation).needBuild.tapAsync(
126
+ PLUGIN_NAME,
127
+ (module, _context, callback) => {
128
+ const [resourcePath] = module.resource?.split('?') ?? []
129
+ // 绕过 Webpack 的模块缓存,让 loader 根据最新 compile-state 将页面 Shell 替换为真实页面。
130
+ if (resourcePath && this.rebuildingFiles.has(resourcePath)) return callback(null, true)
131
+ callback()
132
+ },
133
+ )
134
+
135
+ const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT
136
+ compilation.hooks.processAssets.tap({name: PLUGIN_NAME, stage}, () => {
137
+ if (!this.hasCompletedInitialBuild && compilation.hash && compilation.errors.length === 0) {
138
+ this.emitMissingPageStyleAssets(compilation)
139
+ }
140
+ })
141
+ })
142
+
143
+ compiler.hooks.afterEmit.tap(PLUGIN_NAME, compilation => {
144
+ const isSuccessful = compilation.errors.length === 0 && Boolean(compilation.hash)
145
+ this.hasCompletedInitialBuild = this.hasCompletedInitialBuild || isSuccessful
146
+ })
147
+
148
+ // 当前批次完成后释放快照;编译期间新进入 pendingFiles 的请求不会被清理。
149
+ compiler.hooks.done.tap(PLUGIN_NAME, () => this.rebuildingFiles.clear())
150
+
151
+ compiler.hooks.watchClose.tap(PLUGIN_NAME, () => this.reset())
152
+ }
153
+
154
+ private requestCompile(page: string) {
155
+ try {
156
+ if (isPageCompiled(this.options.paths.appPath, page)) return
157
+ // loader 在下一轮构建中读取该状态;必须先标记,再触发 watch invalidation。
158
+ markPageCompiled(this.options.paths.appPath, page)
159
+ this.pendingFiles.add(resolveMainFilePath(path.resolve(this.options.paths.sourcePath, page)))
160
+ this.compiler?.watching?.invalidate()
161
+ } catch (error) {
162
+ console.error('[taro-hmr-compile] 页面编译触发失败', error)
163
+ }
164
+ }
165
+
166
+ private isPageChunk(compilation: Compilation, pages: Set<string>, chunk: Chunk) {
167
+ if (chunk.name && pages.has(chunk.name)) return true
168
+ return Array.from(pages).some(page => compilation.entrypoints.get(page)?.getEntrypointChunk() === chunk)
169
+ }
170
+
171
+ /** PageShell 不包含真实页面的样式依赖,为缺失的页面样式补充空资产。 */
172
+ private emitMissingPageStyleAssets(compilation: Compilation) {
173
+ const {appConfig, styleFilename} = this.options
174
+ if (!styleFilename) return
175
+
176
+ getAllPages(appConfig).forEach(page => {
177
+ const chunk = compilation.entrypoints.get(page)?.getEntrypointChunk()
178
+ if (!chunk) return
179
+
180
+ const assetName = compilation.getPath(styleFilename, {chunk, contentHashType: 'css/mini-extract'})
181
+ if (!compilation.getAsset(assetName)) compilation.emitAsset(assetName, new RawSource(''))
182
+ })
183
+ }
184
+
185
+ private reset() {
186
+ clearCompiledPages(this.options.paths.appPath)
187
+ this.hasCompletedInitialBuild = false
188
+ this.rebuildingFiles.clear()
189
+ this.pendingFiles.clear()
190
+ }
191
+ }
@@ -0,0 +1,44 @@
1
+ import template from '@babel/template'
2
+ import traverse from '@babel/traverse'
3
+ import generator from '@babel/generator'
4
+ import parser from '@babel/parser'
5
+ import webpack from "webpack";
6
+
7
+ export const injectAppHmrRuntime = (source: string) => {
8
+ const ast = parser.parse(source, {sourceType: 'module', plugins: ['typescript', 'jsx']})
9
+
10
+ const buildHmrCode = template.statements(`
11
+ import { mergePatches, injectHmrLifecycle } from '@taro-hmr-compile/core/runtime'
12
+ injectHmrLifecycle()
13
+ globalThis.miniHmrApplyPatch = (patches)=>{
14
+ const bootstrapPatch = patches.find(patch => patch.type === 'bootstrap')
15
+ return __webpack_require__.miniHmr.applyPatch(bootstrapPatch).then(() => {
16
+ const updatePatch = mergePatches(${webpack.RuntimeGlobals.getFullHash}(), patches)
17
+ return __webpack_require__.miniHmr.applyPatch(updatePatch)
18
+ }).catch(error => {
19
+ console.error('[taro-hmr-compile] patch apply failed', error)
20
+ })
21
+ };
22
+ `, {placeholderPattern: false})
23
+
24
+ traverse(ast, {
25
+ Program(path) {
26
+ const body = path.get('body')
27
+
28
+ const hasInjected = body.some(item => {
29
+ return item.isImportDeclaration() && item.node.source.value === '@taro-hmr-compile/core/runtime'
30
+ })
31
+
32
+ if (hasInjected) return
33
+
34
+ const nodes = buildHmrCode()
35
+
36
+ const lastImport = [...body].reverse().find(item => item.isImportDeclaration())
37
+
38
+ if (lastImport) lastImport.insertAfter(nodes)
39
+
40
+ if (!lastImport) path.unshiftContainer('body', nodes)
41
+ }
42
+ })
43
+ return generator(ast).code
44
+ }
@@ -0,0 +1,5 @@
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
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type {IPluginContext} from '@tarojs/service'
2
+ import type {AppConfig} from '@tarojs/taro'
3
+ import {HMR_OUTPUT_PATH} 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 {setupReactRefresh} from '@taro-hmr-compile/react-refresh/compiler'
9
+ import {emptyDirFile} from '@taro-hmr-compile/core/compiler'
10
+
11
+ export interface WebpackHmrCompileOpt {
12
+ host: string
13
+ port: number
14
+ patchHistoryLimit: number
15
+ }
16
+
17
+ export default (ctx: IPluginContext, pluginOpts: Partial<WebpackHmrCompileOpt>) => {
18
+ if (!ctx.runOpts.options?.isWatch) return
19
+
20
+ const {appPath, sourcePath, outputPath} = ctx.paths
21
+
22
+ const {framework} = ctx.initialConfig
23
+
24
+ const appConfig: AppConfig = getAppConfig(ctx)
25
+
26
+ const host = pluginOpts.host ?? '127.0.0.1'
27
+ const port = pluginOpts.port ?? 8080
28
+ const compileUrl = `http://${host}:${port}/compile`
29
+
30
+ setupReactRefresh(ctx)
31
+
32
+ ctx.onBuildStart(() => emptyDirFile(outputPath))
33
+
34
+ ctx.modifyRunnerOpts(({ opts }) => {
35
+ opts.output ??= {}
36
+ opts.output.clean = false
37
+ })
38
+
39
+ ctx.modifyWebpackChain(({chain, webpack}) => {
40
+
41
+ chain.module
42
+ .rule('hmr-compile')
43
+ .test(/\.[jt]sx?$/)
44
+ .include.add(ctx.paths.sourcePath)
45
+ .end()
46
+ .use('hmr-compile-loader')
47
+ .loader(require.resolve('./compile/hmr-compile-loader'))
48
+ .options({appPath, sourcePath, appConfig, framework, compileUrl})
49
+ .end()
50
+
51
+ chain.output
52
+ .set('hotUpdateChunkFilename', `${HMR_OUTPUT_PATH}/[id].[fullhash].hot-update.js`)
53
+ .set('hotUpdateMainFilename', `${HMR_OUTPUT_PATH}/[runtime].[fullhash].hot-update.json`)
54
+
55
+ const {patchHistoryLimit} = pluginOpts
56
+
57
+ const [{filename: styleFilename}] = chain.plugin('miniCssExtractPlugin')?.get('args') || []
58
+
59
+ chain.plugin('hmr-compiler-plugin').use(HmrCompilerPlugin, [{paths: ctx.paths, appConfig, styleFilename}])
60
+
61
+ chain.plugin('hmr-plugin').use(webpack.HotModuleReplacementPlugin)
62
+
63
+ chain.plugin('hmr-patch-plugin').use(HmrPatchPlugin, [{patchHistoryLimit}])
64
+
65
+ chain.plugin('hmr-server-plugin').use(HmrServerPlugin, [{host, port}])
66
+ })
67
+ }
@@ -0,0 +1,102 @@
1
+ import type {Expression} from '@babel/types'
2
+
3
+ export interface BootstrapPatchSource {
4
+ type: 'bootstrap'
5
+ moduleFactories: Map<string, Expression>
6
+ }
7
+
8
+ export interface UpdatePatchSource {
9
+ type: 'update'
10
+ fromHash: string
11
+ toHash: string
12
+ moduleFactories: Map<string, Expression>
13
+ removedModules: string[]
14
+ }
15
+
16
+ export type PatchSource = BootstrapPatchSource | UpdatePatchSource
17
+
18
+ /** 合并两个连续 update,并按后一份 update 的最终模块状态解决修改和删除冲突。 */
19
+ const mergeUpdatePatches = (basePatch: UpdatePatchSource, nextPatch: UpdatePatchSource): UpdatePatchSource => {
20
+ if (basePatch.toHash !== nextPatch.fromHash) {
21
+ throw new Error(`[taro-hmr-compile] 无法合并不连续的 HMR patch:${basePatch.toHash} !== ${nextPatch.fromHash}`)
22
+ }
23
+
24
+ const moduleFactories = new Map(basePatch.moduleFactories)
25
+ const removedModules = new Set(basePatch.removedModules)
26
+
27
+ nextPatch.moduleFactories.forEach((factory, moduleId) => {
28
+ moduleFactories.set(moduleId, factory)
29
+ removedModules.delete(moduleId)
30
+ })
31
+ nextPatch.removedModules.forEach(moduleId => {
32
+ moduleFactories.delete(moduleId)
33
+ removedModules.add(moduleId)
34
+ })
35
+
36
+ return {
37
+ type: 'update',
38
+ fromHash: basePatch.fromHash,
39
+ toHash: nextPatch.toHash,
40
+ moduleFactories,
41
+ removedModules: Array.from(removedModules),
42
+ }
43
+ }
44
+
45
+ /** 仅维护 bootstrap/update Patch 状态、连续性和压缩,不依赖 Webpack 编译对象。 */
46
+ export class HmrPatchManager {
47
+ private bootstrapPatch?: BootstrapPatchSource
48
+
49
+ private compactedUpdatePatch?: UpdatePatchSource
50
+
51
+ private readonly recentUpdatePatches: UpdatePatchSource[] = []
52
+
53
+ private readonly limit: number
54
+
55
+ constructor(limit: number) {
56
+ this.limit = Math.max(1, limit)
57
+ }
58
+
59
+ clear() {
60
+ this.bootstrapPatch = undefined
61
+ this.clearUpdateHistory()
62
+ }
63
+
64
+ setBootstrap(moduleFactories: Map<string, Expression>) {
65
+ this.bootstrapPatch = moduleFactories.size > 0
66
+ ? {type: 'bootstrap', moduleFactories: new Map(moduleFactories)}
67
+ : undefined
68
+ }
69
+
70
+ /** 追加 update;发现 hash 不连续时丢弃旧 update 链,并在追加后执行容量压缩。 */
71
+ appendUpdate(patch: UpdatePatchSource) {
72
+ const latestPatch = this.recentUpdatePatches[this.recentUpdatePatches.length - 1] ?? this.compactedUpdatePatch
73
+ if (latestPatch && latestPatch.toHash !== patch.fromHash) this.clearUpdateHistory()
74
+ this.recentUpdatePatches.push(patch)
75
+ this.compactUpdateHistory()
76
+ }
77
+
78
+ /** 按客户端消费顺序返回 bootstrap、压缩 update 与近期 update。 */
79
+ getPatches(): PatchSource[] {
80
+ const updatePatches = this.compactedUpdatePatch
81
+ ? [this.compactedUpdatePatch, ...this.recentUpdatePatches]
82
+ : this.recentUpdatePatches
83
+ return this.bootstrapPatch
84
+ ? [this.bootstrapPatch, ...updatePatches]
85
+ : updatePatches
86
+ }
87
+
88
+ private clearUpdateHistory() {
89
+ this.compactedUpdatePatch = undefined
90
+ this.recentUpdatePatches.length = 0
91
+ }
92
+
93
+ /** 将超出上限的旧 update 逐步合并到首个 update;bootstrap 永不参与 merge。 */
94
+ private compactUpdateHistory() {
95
+ while (this.recentUpdatePatches.length > this.limit) {
96
+ const oldestUpdatePatch = this.recentUpdatePatches.shift()!
97
+ this.compactedUpdatePatch = this.compactedUpdatePatch
98
+ ? mergeUpdatePatches(this.compactedUpdatePatch, oldestUpdatePatch)
99
+ : oldestUpdatePatch
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,237 @@
1
+ import generator from '@babel/generator'
2
+ import {parse} from '@babel/parser'
3
+ import template from '@babel/template'
4
+ import traverse from '@babel/traverse'
5
+ import * as t from '@babel/types'
6
+ import type {Compilation, Compiler} from 'webpack'
7
+ import {RawSource} from 'webpack-sources'
8
+ import {DEFAULT_HMR_PATCH_HISTORY_LIMIT, HMR_PATCH_ASSET} from '../constants'
9
+ import {HmrCompilerPlugin} from '../compile/hmr-compiler-plugin'
10
+ import {HmrPatchManager} from '../patch/hmr-patch-manager'
11
+ import type {PatchSource, UpdatePatchSource} from '../patch/hmr-patch-manager'
12
+ import {HmrPatchRuntimePlugin} from './hmr-patch-runtime-plugin'
13
+
14
+ const PLUGIN_NAME = 'HmrPatchPlugin'
15
+ const HOT_UPDATE_MANIFEST_SUFFIX = '.hot-update.json'
16
+ const HOT_UPDATE_SCRIPT_SUFFIX = '.hot-update.js'
17
+ const HOT_UPDATE_HASH_PATTERN = /\.([0-9a-f]+)\.hot-update\.json$/i
18
+ const STYLE_ASSET_PATTERN = /\.(?:css|wxss|acss|ttss|qss|jxss)(?:\.map)?$/i
19
+ const TEMPLATE_RUNTIME_ASSET_PATTERN = /(?:^|\/)(?:base\.wxml|utils\.wxs)$/i
20
+
21
+ interface HotUpdateManifest {
22
+ m?: Array<string | number>
23
+ }
24
+
25
+ type CompilationAsset = ReturnType<Compilation['getAssets']>[number]
26
+
27
+ interface HotUpdateAssets {
28
+ manifests: CompilationAsset[]
29
+ scripts: CompilationAsset[]
30
+ }
31
+
32
+ export interface HmrPatchPluginOptions {
33
+ patchHistoryLimit?: number
34
+ }
35
+
36
+ const collectModuleFactories = (source: string, moduleFactories: Map<string, t.Expression>) => {
37
+ const ast = parse(`(${source})`, {sourceType: 'script'})
38
+ const statement = ast.program.body[0]
39
+ if (!t.isExpressionStatement(statement) || !t.isObjectExpression(statement.expression)) {
40
+ throw new Error('[taro-hmr-compile] Page Chunk 模块段不是对象表达式')
41
+ }
42
+
43
+ statement.expression.properties.forEach(property => {
44
+ if (!t.isObjectProperty(property) || !t.isExpression(property.value)) {
45
+ throw new Error('[taro-hmr-compile] Page Chunk 模块对象包含无法识别的属性')
46
+ }
47
+
48
+ const {key} = property
49
+ let moduleId: string
50
+ if (t.isStringLiteral(key) || t.isNumericLiteral(key)) moduleId = String(key.value)
51
+ else if (t.isIdentifier(key) && !property.computed) moduleId = key.name
52
+ else throw new Error('[taro-hmr-compile] Page Chunk 模块对象包含无法识别的模块 ID')
53
+
54
+ moduleFactories.set(moduleId, property.value)
55
+ })
56
+ }
57
+
58
+ /** 将本轮 Webpack hot-update 产物转换为内部 update 表示。 */
59
+ const createUpdatePatch = (toHash: string, assets: HotUpdateAssets): UpdatePatchSource => {
60
+ const manifestAsset = assets.manifests[0]
61
+ const hashMatch = manifestAsset.name.match(HOT_UPDATE_HASH_PATTERN)
62
+ if (!hashMatch) throw new Error(`[taro-hmr-compile] 无法从 ${manifestAsset.name} 提取 HMR 基线 hash`)
63
+
64
+ const moduleFactories = new Map<string, t.Expression>()
65
+ assets.scripts.forEach(asset => {
66
+ const ast = parse(asset.source.source().toString(), {sourceType: 'script'})
67
+
68
+ traverse(ast, {
69
+ CallExpression(nodePath) {
70
+ const {callee} = nodePath.node
71
+ if (!t.isMemberExpression(callee) || !t.isIdentifier(callee.object, {name: 'wx'})) return
72
+ if (!t.isStringLiteral(callee.property) || !callee.property.value.startsWith('webpackHotUpdate')) return
73
+
74
+ const modules = nodePath.node.arguments[1]
75
+ if (!t.isObjectExpression(modules)) return
76
+
77
+ modules.properties.forEach(property => {
78
+ if (!t.isObjectProperty(property) || !t.isExpression(property.value)) {
79
+ throw new Error('[taro-hmr-compile] HMR 模块对象包含无法识别的属性')
80
+ }
81
+
82
+ const {key} = property
83
+ let moduleId: string
84
+ if (t.isStringLiteral(key) || t.isNumericLiteral(key)) moduleId = String(key.value)
85
+ else if (t.isIdentifier(key) && !property.computed) moduleId = key.name
86
+ else throw new Error('[taro-hmr-compile] HMR 模块对象包含无法识别的模块 ID')
87
+
88
+ moduleFactories.set(moduleId, property.value)
89
+ })
90
+ },
91
+ })
92
+ })
93
+
94
+ const removedModules = new Set<string>()
95
+ assets.manifests.forEach(asset => {
96
+ const manifest = JSON.parse(asset.source.source().toString()) as HotUpdateManifest
97
+ manifest.m?.forEach(moduleId => removedModules.add(String(moduleId)))
98
+ })
99
+
100
+ return {
101
+ type: 'update',
102
+ fromHash: hashMatch[1],
103
+ toHash,
104
+ moduleFactories,
105
+ removedModules: Array.from(removedModules),
106
+ }
107
+ }
108
+
109
+ /** 将内部 Patch 表示序列化为固定 patch.js。 */
110
+ const createPatchAssetSource = (patches: PatchSource[]): string => {
111
+ const buildBootstrapPatchNode = template.expression(`({
112
+ type: 'bootstrap',
113
+ modules: %%MODULES%%
114
+ })`)
115
+ const buildUpdatePatchNode = template.expression(`({
116
+ type: 'update',
117
+ fromHash: %%FROM_HASH%%,
118
+ toHash: %%TO_HASH%%,
119
+ modules: %%MODULES%%,
120
+ removedModules: %%REMOVED_MODULES%%
121
+ })`)
122
+ const patchNodes = patches.map(patch => {
123
+ const modules = t.objectExpression(Array.from(
124
+ patch.moduleFactories,
125
+ ([moduleId, factory]) => t.objectProperty(t.stringLiteral(moduleId), factory),
126
+ ))
127
+ if (patch.type === 'bootstrap') return buildBootstrapPatchNode({MODULES: modules})
128
+
129
+ return buildUpdatePatchNode({
130
+ FROM_HASH: t.stringLiteral(patch.fromHash),
131
+ TO_HASH: t.stringLiteral(patch.toHash),
132
+ MODULES: modules,
133
+ REMOVED_MODULES: t.arrayExpression(patch.removedModules.map(moduleId => t.stringLiteral(moduleId))),
134
+ })
135
+ })
136
+
137
+ const statements = template.statements('module.exports = %%PATCHES%%;')({PATCHES: t.arrayExpression(patchNodes)})
138
+ return generator(t.program(statements)).code
139
+ }
140
+
141
+ const isPassthroughAsset = (assetName: string) => {
142
+ return STYLE_ASSET_PATTERN.test(assetName) || TEMPLATE_RUNTIME_ASSET_PATTERN.test(assetName)
143
+ }
144
+
145
+ export class HmrPatchPlugin {
146
+ private hasCompletedInitialBuild = false
147
+
148
+ private readonly patchManager: HmrPatchManager
149
+
150
+ private readonly emittedAssetSources = new Map<string, string>()
151
+
152
+ constructor(options: HmrPatchPluginOptions) {
153
+ this.patchManager = new HmrPatchManager(options.patchHistoryLimit ?? DEFAULT_HMR_PATCH_HISTORY_LIMIT)
154
+ }
155
+
156
+ apply(compiler: Compiler) {
157
+ new HmrPatchRuntimePlugin().apply(compiler)
158
+
159
+ compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
160
+ const initialPageFactories = new Map<string, t.Expression>()
161
+
162
+ HmrCompilerPlugin.getCompilationHooks(compilation).pageChunkModules.tap(PLUGIN_NAME, source => {
163
+ collectModuleFactories(source, initialPageFactories)
164
+ })
165
+
166
+ // 等 Webpack 生成完整的 hot-update assets 后,再统一转换成固定的 patch.js。
167
+ const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT
168
+ compilation.hooks.processAssets.tap({name: PLUGIN_NAME, stage}, () => {
169
+ this.processAssets(compilation, initialPageFactories)
170
+ })
171
+ })
172
+
173
+ compiler.hooks.afterEmit.tap(PLUGIN_NAME, compilation => {
174
+ const isSuccessful = compilation.errors.length === 0 && Boolean(compilation.hash)
175
+ this.hasCompletedInitialBuild = this.hasCompletedInitialBuild || isSuccessful
176
+ })
177
+
178
+ compiler.hooks.watchClose.tap(PLUGIN_NAME, () => this.reset())
179
+ }
180
+
181
+ private processAssets(compilation: Compilation, initialPageFactories: Map<string, t.Expression>) {
182
+ if (!compilation.hash || compilation.errors.length > 0) return
183
+
184
+ const originalAssets = compilation.getAssets()
185
+ if (!this.hasCompletedInitialBuild) {
186
+ this.patchManager.clear()
187
+ this.emittedAssetSources.clear()
188
+ originalAssets.forEach(asset => {
189
+ if (isPassthroughAsset(asset.name)) {
190
+ this.emittedAssetSources.set(asset.name, asset.source.source().toString())
191
+ }
192
+ })
193
+ this.patchManager.setBootstrap(initialPageFactories)
194
+ this.emitPatchAsset(compilation, false)
195
+ return
196
+ }
197
+
198
+ const hotUpdateAssets: HotUpdateAssets = {
199
+ manifests: originalAssets.filter(asset => asset.name.endsWith(HOT_UPDATE_MANIFEST_SUFFIX)),
200
+ scripts: originalAssets.filter(asset => asset.name.endsWith(HOT_UPDATE_SCRIPT_SUFFIX)),
201
+ }
202
+
203
+ if (hotUpdateAssets.manifests.length) {
204
+ this.patchManager.appendUpdate(createUpdatePatch(compilation.hash, hotUpdateAssets))
205
+ this.emitPatchAsset(compilation, true)
206
+ }
207
+
208
+ // 增量 compilation 仍可能包含普通 chunk 资产,不能让它们覆盖初始 PageShell。
209
+ this.removeSupersededAssets(compilation, originalAssets)
210
+ }
211
+
212
+ /** 热更新转换完成后,保留固定 patch 与发生变化的模板、平台样式资产。 */
213
+ private removeSupersededAssets(compilation: Compilation, assets: CompilationAsset[]) {
214
+ assets.forEach(asset => {
215
+ if (asset.name === HMR_PATCH_ASSET) return
216
+ if (!isPassthroughAsset(asset.name)) return compilation.deleteAsset(asset.name)
217
+
218
+ const source = asset.source.source().toString()
219
+ const previousSource = this.emittedAssetSources.get(asset.name)
220
+ this.emittedAssetSources.set(asset.name, source)
221
+ if (previousSource === source) compilation.deleteAsset(asset.name)
222
+ })
223
+ }
224
+
225
+ private emitPatchAsset(compilation: Compilation, isHotUpdate: boolean) {
226
+ const source = new RawSource(createPatchAssetSource(this.patchManager.getPatches()))
227
+ const assetInfo = {hotModuleReplacement: isHotUpdate}
228
+ if (compilation.getAsset(HMR_PATCH_ASSET)) compilation.updateAsset(HMR_PATCH_ASSET, source, assetInfo)
229
+ else compilation.emitAsset(HMR_PATCH_ASSET, source, assetInfo)
230
+ }
231
+
232
+ private reset() {
233
+ this.hasCompletedInitialBuild = false
234
+ this.emittedAssetSources.clear()
235
+ this.patchManager.clear()
236
+ }
237
+ }