fex-team / fit

百度 React 组件库
http://fit.baidu.com
351 stars 50 forks source link

React 通用组件管理源码剖析 #4

Open ascoders opened 8 years ago

ascoders commented 8 years ago

如何有效编译、发布组件,同时组织好组件之间依赖关联是这篇文章要解决的问题。

目标

比如现在有 navbar resource-card 这两个组件,并且 resource-card 依赖了 navbar,现在通过命令:

npm run manage -- --publish wefan/navbar#major

给 navbar 发布一个主要版本号,会提示下图确认窗口,check一遍发布级别、实际发布级别、当前版本号与发布版本号是否符合预期,当复合预期后,再正式发布组件。

78c063f0ab5dc73a6985aba1d

上图的发布级别,可以看到 resource-card 因为直接依赖了 navbar,而 navbar 发布了大版本号产生了 break change,因此依赖它的 resource-card 连带升级一个 minor 新版本号。

而依赖关系是通过脚本分析,实际开发中不需要关心组件之间的依赖关系,当发布时,程序自动整理出组件的依赖关系,并且根据发布的版本号判断哪些组件要连带更新。同时对直接更新的组件进行编译,对直接依赖,但非直接发布的组件只进行发布。

最后,为了保证组件发布的安全性,将依赖本次发布组件最少的组件优先发布,避免因为发布失败,而让线上组件引用了一个未发布的版本。

安装 commander

commander 可以让 nodejs 方便接收用户输入参数。现在一个项目下有N个组件,我们对这些组件的期望操作是——更新、提交、发布:

commander.version('1.0.0')
    .option('-u, --update', '更新')
    .option('-p, --push', '提交')
    .option('-pub, --publish', '发布')

定义子组件结构

组件可能是通用的、业务定制的,我们给组件定一个分类:

export interface Category {
    /**
     * 分类名称
     */
    name: string
    /**
     * 分类中文名
     */
    chinese: string
    /**
     * 发布时候的前缀
     */
    prefix: string
    /**
     * 是否隐私
     * private: 提交、发布到私有仓库
     * public: 提交、发布到公有仓库
     */
    isPrivate: boolean
    /**
     * 组件列表
     */
    components?: Array<ComponentConfig>
}

每个组件只需要一个组件名(对应仓库名)和中文名:

export interface ComponentConfig {
    /**
     * 组件名(不带前缀)
     */
    name: string
    /**
     * 中文名
     */
    chinese: string
}

更新组件

采用 subtree 管理子组件仓库,对不存在项目中的组件,从仓库中拖拽下来,对存在的组件,从远程仓库更新

node manage.js --update
components.forEach(category=> {
    category.components.forEach(component=> {
        // 组件根目录
        const componentRootPath = `${config.componentsPath}/${category.name}/${component.name}`

        if (!fs.existsSync(componentRootPath)) { 
            // 如果组件不存在, 添加
            execSync(`git subtree add -P ${componentRootPath} ${config.privateGit}/${category.name}-${component.name}.git master`)
        } else {
            // 组件存在, 更新
            execSync(`git subtree pull -P ${componentRootPath} ${config.privateGit}/${category.name}-${component.name}.git master`)
        }
    })
})

提交组件

采用 subtree 管理,在提交子组件之前在根目录统一提交, 再循环所有组件进行 subtree 提交

execSync(`git add -A`)
execSync(`git commit -m "${message}"`)

发布组件

首先遍历所有组件,将其依赖关系分析出来:

filesPath.forEach(filePath=> {
    const source = fs.readFileSync(filePath).toString()
    const regex = /import\s+[a-zA-Z{},\s\*]*(from)?\s?\'([^']+)\'/g

    let match: any
    while ((match = regex.exec(source)) != null) {
        // 引用的路径
        const importPath = match[2] as string
        importPaths.set(importPath, filePath)
    }
})

根据是否含有 ./ 或者 ../ 开头,判断这个依赖是 npm 的还是其它组件的:

if (importPath.startsWith('./') || importPath.startsWith('../')) {
    // 是个相对引用
    // 引用模块的完整路径
    const importFullPath = path.join(filePathDir, importPath)
    const importFullPathSplit = importFullPath.split('/')

    if (`${config.componentsPath}/${importFullPathSplit[1]}/${importFullPathSplit[2]}` !== componentPath) {
        // 保证引用一定是 components 下的
        deps.dependence.push({
            type: 'component',
            name: importFullPathSplit[2],
            category: importFullPathSplit[1]
        })
    }
} else {
    // 绝对引用, 暂时认为一定引用了 node_modules 库
    deps.dependence.push({
        type: 'npm',
        name: importPath
    })
}

接下来使用 ts 编译。因为 typescript 生成 d.ts 方式只能针对文件为入口,首先构造一个入口文件,引入全部组件,再执行 tsc -d 将所有组件编译到 built 目录下:

execSync(`tsc -m commonjs -t es6 -d --removeComments --outDir built-components --jsx react ${comboFilePath}`)

再遍历用户要发布的组件,编译其 lib 目录(将 typescript 编译后的文件使用 babel 编译,提高对浏览器兼容性),之后根据提交版本判断是否要将其依赖的组件提交到待发布列表:

if (componentInfo.publishLevel === 'major') {
    // 如果发布的是主版本, 所有对其直接依赖的组件都要更新 patch
    // 寻找依赖这个组件的组件
    allComponentsInfoWithDep.forEach(componentInfoWithDep=> {
        componentInfoWithDep.dependence.forEach(dep=> {
            if (dep.type === 'component' && dep.category === componentInfo.publishCategory.name && dep.name === componentInfo.publishComponent.name) {
                // 这个组件依赖了当前要发布的组件, 而且这个发布的还是主版本号, 因此给它发布一个 minor 版本
                // 不需要更新其它依赖, package.json 更新依赖只有要发布的组件才会享受, 其它的又不发布, 不需要更新依赖, 保持版本号更新发个新版本就行了, 他自己的依赖会在发布他的时候修正
                addComponentToPublishComponents(componentInfoWithDep.component, componentInfoWithDep.category, 'minor')
            }
        })
    })
}

现在我们需要将发布组件排序,依照其对这次发布组件的依赖数量,由小到大排序。我们先创建一个模拟发布的队列,每当认定一个组件需要发布,便将这个组件 push 到这个队列中,并且下次判断组件依赖时忽略掉模拟发布队列中的组件,直到到模拟发布组件长度为待发布组件总长度,这个模拟发布队列就是我们想要的发布排序:

// 添加未依赖的组件到模拟发布队列, 直到队列长度与发布组件长度相等
while (simulations.length !== allPublishComponents.length) {
    pushNoDepPublishComponents()
}
/**
 * 遍历要发布的组件, 将没有依赖的(或者依赖了组件,但是在模拟发布队列中)组件添加到模拟发布队列中
 */
const pushNoDepPublishComponents = ()=> {
    // 为了防止对模拟发布列表的修改影响本次判断, 做一份拷贝
    const simulationsCopy = simulations.concat()

    // 遍历要发布的组件
    allPublishComponents.forEach(publishComponent=> {
        // 过滤已经在发布队列中的组件
        // ...

        // 是否依赖了本次发布的组件
        let isRelyToPublishComponent = false

        publishComponent.componentInfoWithDep.dependence.forEach(dependence=> {
            if (dependence.type === 'npm') {
                // 不看 npm 依赖
                return
            }

            // 遍历要发布的组件
            for (let elPublishComponent of allPublishComponents) {
                // 是否在模拟发布列表中
                let isInSimulation = false
                // ..
                if (isInSimulation) {
                    // 如果这个发布的组件已经在模拟发布组件中, 跳过
                    continue
                }

                if (elPublishComponent.componentInfoWithDep.component.name === dependence.name && elPublishComponent.componentInfoWithDep.category.name === dependence.category) {
                    // 这个依赖在这次发布组件中
                    isRelyToPublishComponent = true
                    break
                }
            }
        })

        if (!isRelyToPublishComponent) {
            // 这个组件没有依赖本次要发布的组件, 把它添加到发布列表中
            simulations.push(publishComponent)
        }
    })
}

发布队列排好后,使用 tty-table 将模拟发布队列优雅的展示在控制台上,正是文章开头的组件发布确认图。再使用 prompt 这个包询问用户是否确认发布,因为目前位置,所有发布操作都是模拟的,如果用户发现了问题,可以随时取消这次发布,不会造成任何影响:

prompt.start()
prompt.get([{
    name: 'publish',
    description: '以上是最终发布信息, 确认发布吗? (true or false)',
    message: '选择必须是 true or false 中的任意一个',
    type: 'boolean',
    required: true
}], (err: Error, result: any) => {
    // ...
})

接下来我们将分析好的依赖数据写入每个组件的 package.json 中,在根目录提交(提交这次 package.json 的修改),遍历组件进行发布。对于内部模块,我们一般会提交到内部 git 仓库,使用 tag 进行版本管理,这样安装的时候便可以通过 xxx.git#0.0.1 按版本号进行控制:

// 打 tag
execSync(`cd ${publishPath}; git tag v${publishInfo.componentInfoWithDep.packageJson.version}`)

// push 分支
execSync(`git subtree push -P ${publishPath} ${config.privateGit}/${publishInfo.componentInfoWithDep.category.name}-${publishInfo.componentInfoWithDep.component.name}.git v${publishInfo.componentInfoWithDep.packageJson.version}`)

// push 到 master
execSync(`git subtree push -P ${publishPath} ${config.privateGit}/${publishInfo.componentInfoWithDep.category.name}-${publishInfo.componentInfoWithDep.component.name}.git master`)

// 因为这个 tag 也打到了根目录, 所以在根目录删除这个 tag
execSync(`git tag -d v${publishInfo.componentInfoWithDep.packageJson.version}`)

因为对于 subtree 打的 tag 会打在根目录上,因此打完 tag 并提交了 subtree 后,删除根目录的 tag。最后对根目录提交,因为对 subtree 打 tag 的行为虽然也认定为一次修改,即便没有源码的变更:

// 根目录提交
execSync(`git push`)

总结

目前通过 subtree 实现多 git 仓库管理,并且对组件依赖联动分析、版本发布和安全控制做了处理,欢迎拍砖。