Skip to content

Commit

Permalink
feat(plugin-compiler): 添加幽灵依赖检测功能 (#7)
Browse files Browse the repository at this point in the history
* feat(plugin-compiler): add phantom dependency check

* docs(website): add phantom dependency docs

* feat(plugin-compiler): add phantom dependency check

* docs(website): update phantom dependency docs style

* feat(plugin-compiler): add pwd path restriction

* feat(plugin-compiler): compatible with alias configuration

* feat(plugin-compiler): compatible with consumes configuration

* feat(plugin-compiler): change cwd

* feat(plugin-compiler): 删除生产环境关闭的限制,把幽灵依赖检测收束为配置开关

* feat(plugin-compiler): 添加 runner.userConfig 的空对象兜底,防止出现 undefined 报错

* feat(plugin-compiler): 添加 alias 等值的兜底,给 readJSONSync 添加 try 方法以防止未知错误
  • Loading branch information
BboyZaki authored Mar 29, 2023
1 parent 20de557 commit 9899827
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 0 deletions.
27 changes: 27 additions & 0 deletions packages/plugin-compiler/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ export const CSSMinimizerTypes = objectEnum([
// CSS 压缩特性
export const CompressCssStrategies = objectEnum(['lite'])

// 幽灵依赖检测模式
export const PhantomDependencyMode = objectEnum(['warn', 'error'])

/**
* 编译命令行及用户配置条目
*/
Expand Down Expand Up @@ -574,6 +577,21 @@ export const CompilerCliConfig: ICliConfig = {
}
},

// 是否开启幽灵依赖检查
phantomDependency: {
name: '是否开启幽灵依赖检查',
children: {
mode: {
name: '开启幽灵依赖检查的模式',
desc: '不同模式下幽灵依赖检查的程度不同'
},
exclude: {
name: '不作为幽灵依赖的 npm 包',
desc: '开启幽灵依赖检查时,不被认为是幽灵依赖的 npm 包'
}
}
},

// 模拟 app.x 文件
mockAppEntry: {
name: '模拟 app.x 的文件配置',
Expand Down Expand Up @@ -897,6 +915,15 @@ export const CompilerUserConfigSchema = z.object({
})
.or(z.boolean())
.default(true),
phantomDependency: z
.object({
mode: z
.nativeEnum(PhantomDependencyMode)
.default(PhantomDependencyMode.warn),
exclude: z.array(z.string()).optional()
})
.or(z.boolean())
.default(false),
compilerOptions: z
.object({
/**
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { GenerateComposedAppJsonFilePlugin } from './plugins/generateComposedApp
import { InjectGetAppPlugin } from './plugins/injectGetAppPlugin'
import { ModuleSharingAndConsumingPlugin } from './plugins/moduleSharingAndConsumingPlugin'
import { OptimizeSplitChunksPlugin } from './plugins/optimizeSplitChunksPlugin'
import { PhantomDependencyPlugin } from './plugins/phantomDependencyPlugin'
import { ProgressPlugin } from './plugins/progressPlugin'
import { RuntimeInjectPlugin } from './plugins/runtimeInjectPlugin'
import { preprocess } from './preprocessors/codePreprocessor'
Expand Down Expand Up @@ -81,6 +82,7 @@ class MorCompile {
new DynamicRequireSupportPlugin().apply(runner)
new AliasSupportPlugin().apply(runner)
new DefineSupportPlugin().apply(runner)
new PhantomDependencyPlugin().apply(runner)

// 应用 parser 插件
new ConfigParserPlugin().apply(runner)
Expand Down
211 changes: 211 additions & 0 deletions packages/plugin-compiler/src/plugins/phantomDependencyPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {
fsExtra as fs,
logger,
Plugin,
Runner,
tsTransformerFactory,
typescript as ts,
WebpackWrapper
} from '@morjs/utils'
import path from 'path'
import { NODE_MODULE_REGEXP } from '../constants'

/**
* 检测项目中的幽灵依赖
* 以下情况直接跳过该文件的检测
* - 生产环境
* - phantomDependency 为 false 时
* - 该文件为 node_modules 文件
* - 文件不在当前 pwd 运行路径内
*/
export class PhantomDependencyPlugin implements Plugin {
name = 'PhantomDependencyPlugin'
webpackWrapper: WebpackWrapper

apply(runner: Runner<any>) {
runner.hooks.webpackWrapper.tap(this.name, (webpackWrapper) => {
this.webpackWrapper = webpackWrapper
})

const usedDependencies: Record<string, string> = {}

runner.hooks.beforeRun.tap(this.name, () => {
const { phantomDependency } = runner.userConfig || {}
if (!phantomDependency) return

runner.hooks.scriptParser.tap(this.name, (transformers, options) => {
const fileInfoPath = options.fileInfo.path || ''
if (
!NODE_MODULE_REGEXP.test(fileInfoPath) &&
fileInfoPath.includes(runner.getCwd())
) {
this.handlePhantomTransformer(
transformers,
fileInfoPath,
usedDependencies
)
}
return transformers
})

runner.hooks.sjsParser.tap(this.name, (transformers, options) => {
const fileInfoPath = options.fileInfo.path || ''
if (
!NODE_MODULE_REGEXP.test(fileInfoPath) &&
fileInfoPath.includes(runner.getCwd())
) {
this.handlePhantomTransformer(
transformers,
fileInfoPath,
usedDependencies
)
}
return transformers
})
})

runner.hooks.done.tap(this.name, () => {
const {
alias = {},
srcPaths = [],
phantomDependency,
externals = [],
consumes = [],
watch
} = runner.userConfig || {}
if (!phantomDependency) return

let allDependencies = { ...this.getPkgDepend(runner.getCwd()) }
srcPaths.map((srcPath) => {
allDependencies = { ...allDependencies, ...this.getPkgDepend(srcPath) }
})

// 跳过 alias 及以 alias 中以 key 开头的依赖包
const aliasAll = {
...alias,
...this.webpackWrapper?.config?.resolve?.alias
}
for (const aliasKey in aliasAll) {
if (aliasKey.endsWith('$')) {
aliasAll[aliasKey.substring(0, aliasKey.length - 1)] =
aliasAll[aliasKey]
delete aliasAll[aliasKey]
}
}

// 跳过在 externals 或 consumes 中配置的包
const otherDepends = [...externals, ...consumes].map((item) =>
this.getExternalsPkgName(item)
)

const table = {
head: ['依赖名', '引用地址'],
rows: []
}

// 跳过已在 package.json 和 phantomDependency.exclude 中配置的依赖
for (const depKey in usedDependencies) {
if (
!(phantomDependency.exclude || []).includes(depKey) &&
!allDependencies[depKey] &&
!aliasAll[depKey] &&
!aliasAll[depKey.split('/')[0]] &&
!otherDepends.includes(depKey)
) {
table.rows.push([depKey, usedDependencies[depKey]])
}
}

if (table.rows.length > 0) {
if (phantomDependency.mode === 'error') {
logger.error('检测到幽灵依赖,请添加到 package.json')
logger.table(table, 'error')

if (!watch) {
// 非 watch 状态下,确保 error 模式,可以异常退出
const error = new Error()
error['isErrorLogged'] = true
error.stack = ''
throw error
}
} else {
logger.warnOnce('检测到幽灵依赖,请添加到 package.json')
logger.table(table, 'warn')
}
}
})
}

// 检查 script 和 sjs 文件 获取 import 和 require 的依赖进行检测
handlePhantomTransformer(transformers, fileInfoPath, usedDependencies) {
transformers.before.push(
tsTransformerFactory((node) => {
if (!node) return node

// 处理 import 节点
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const pkgName = this.getPkgName(node.moduleSpecifier?.text)
// 跳过以 . / @/ @@/ 开头的本地文件引用
if (!/^(\.|\/|@\/|@@\/)/.test(pkgName))
usedDependencies[pkgName] = fileInfoPath
}

// 处理 require 节点
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression?.escapedText === 'require' &&
ts.isStringLiteral(node.arguments?.[0])
) {
const pkgName = this.getPkgName(node.arguments?.[0].text)
// 跳过以 . / @/ @@/ 开头的本地文件引用
if (!/^(\.|\/|@\/|@@\/)/.test(pkgName))
usedDependencies[pkgName] = fileInfoPath
}
return node
})
)
}

/**
* 获取依赖的 npm 包名
* @param source - string 被使用依赖
*/
getPkgName(source: string) {
const arr = source.split('/')
if (source.startsWith('@')) return arr[1] ? arr[0] + '/' + arr[1] : arr[0]
return arr[0]
}

/**
* 获取指定路径 package.json 内的所有依赖
* @param path 路径
*/
getPkgDepend(filePath: string) {
if (fs.existsSync(path.join(filePath, 'package.json'))) {
try {
const pkgJson = fs.readJSONSync(path.join(filePath, 'package.json'))
return { ...pkgJson.devDependencies, ...pkgJson.dependencies }
} catch (err) {
logger.warn(
`${path.join(filePath, 'package.json')} 文件读取错误: ${err}`
)
}
}
return {}
}

/**
* 获取 externals 或 consumes 的依赖名
* @param item 配置子项
*/
getExternalsPkgName(item) {
if (typeof item === 'string') return item
if (Object.prototype.toString.call(item) === '[object Object]') {
return Object.keys(item)[0]
}
}
}
40 changes: 40 additions & 0 deletions website/docs/guides/basic/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,46 @@ js 压缩器自定义配置, 使用时请结合 `jsMinimizer` 所指定的压缩

**注意:不允许设定为 `src``mock``.mor` 等约定式功能相关的目录。**

### phantomDependency - 幽灵依赖检测

- 类型: `object``boolean`
- 默认值: 开发模式 `true` | 生产模式 `false`

开启关闭或配置幽灵依赖检测功能,不配置时开发模式下默认为 `true` 开启检测 warn 警告,生产模式下默认为 `false` 关闭检测,配置值为 `object` 时支持 `mode``exclude` 两个属性:

- `mode`: 检测模式,可配置为 `'warn'``'error'` 两种,默认 `'warn'` 时仅进行警告,配置为 `'error'` 时会作为错误抛出
- `exclude`: `Array<string>` 指定哪些 npm 包不作为幽灵依赖从而跳过检测

```javascript
// 配置示例一:关闭检测(生产模式下默认)
{
phantomDependency: false
}

// 配置示例二:开启检测 warn 警告,但是某些包不判断为幽灵依赖
{
phantomDependency: {
mode: 'warn',
exclude: ['@morjs/utils']
}
}

// 配置示例三:开启检测 error 警告,但是某些包不判断为幽灵依赖
{
phantomDependency: {
mode: 'error',
exclude: ['@morjs/utils']
}
}
```

> - 幽灵依赖: 当一个项目使用了一个没有在其 package.json 中声明的包时,就会出现"幽灵依赖"<br/>
- 出现原因: npm 3.x 开始「改进」了安装算法,使其扁平化,扁平化就是把深层的依赖往上提。好处是消除重复依赖,代价则是引入幽灵依赖问题,因为往上提的依赖你在项目中引用时就能跑<br/>
- 潜在危害:<br/>
- 不兼容的版本,比如某依赖过了一年发布大版本,然后大版本被提升到 node_modules root 目录,你就会使用不兼容的版本<br/>
- 依赖缺失,比如你的直接依赖小版本更新后不使用你之前依赖的间接依赖,再次安装时就不会出现这个依赖,或者比如多个直接依赖的间接依赖冲突时,可能也不会做提升

### plugins - 插件配置

- 类型: `Plugin[]`
Expand Down

0 comments on commit 9899827

Please sign in to comment.