微前端qiankun框架的底层实现原理

2023-11-04 01:41

本文主要是介绍微前端qiankun框架的底层实现原理,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

qiankun框架的底层原理

现在我们手写一个简易的qiankun框架,实现基础的父应用中切换路径跳转子应用、沙箱隔离等qiankun框架的功能。

我们在已经配置好的qiankun主应用中不使用qinakun框架,而是引入我们自己手写的简易框架来实现本有的qiankun框架功能比如路由跳转、沙箱隔离、css样式隔离等。

// 引入qinakun框架内的方法
// import { registerMicroApps, start } from "qiankun"
// 引入自己写的src路径下的微前端框架micro_qiankun
import { registerMicroApps, start } from "./micro_qiankun"

手写qiankun的registerMicroApps()和start()方法

registerMicroApps是用来根据传入的子应用的信息数组来注册子应用;start使用启动这些子应用。

微前端的运行原理:1. 监听路由变化 2.匹配子应用 3.加载子应用 4.渲染子应用

项目目录,其中micro-qiankun就是自己仿照qinakun功能写的一个简易版微前端库。下面我把里面包含的api列出来,可详细看。

image-20220316113629584

入口:index.js:

import { handleRouter } from "./handle-router"
import { rewriteRouter } from "./rewrite-router"
// 暂存的apps,主要考虑到外部要拿这个要注册的子应用
let _apps = []
// 外部拿到子应用列表所要执行的方法
const getApps = function () {return _apps
}// 注册子应用的方法
const registerMicroApps = function (apps) {_apps = appsconsole.log(apps);
}// 启动子应用的方法
const start = function () {// 微前端的运行原理,1. 监听路由变化 2.匹配子应用 3.加载子应用 4.渲染子应用// 1. 监听路由变化rewriteRouter()// 初始执行匹配handleRouter()// 2.匹配子应用// 3.加载子应用// 4.渲染子应用
}// 导出
export { registerMicroApps, start, getApps }

handle-router.js:

// 此函数用来处理路由跳转后,让其处理子应用即匹配子应用、加载子应用、渲染子应用
import { getApps } from ".";
import { importHTML } from "./import-html";
import { getNextRoute, getPrevRoute } from "./rewrite-router";
export const handleRouter = async function () {const apps = getApps() //获取当前子应用列表数组// 需要先判断是否还有上一个子应用let preRoute = getPrevRoute() //获取上一个路由路径window.location.pathnamelet nextRoute = getNextRoute() //获取跳转后的路由路径// 获取上一个路由的子应用const preApp = apps.find(item => preRoute.startsWith(item.activeRule))// 2.2 然后子apps子应用中查找// [name, entry, container, activeRule, mount, unmout, bootStrap]// 获取跳转后的子应用const app = apps.find(item => nextRoute.startsWith(item.activeRule))//str.startWith(str1),字符串str如果以str1开头,那么就返回true// 如果有上一个应用,那么就先销毁,然后再加载当前的子应用if (preApp) {await unmount(preApp)//此时preApp已经有自己的声明周期钩子的,这是在上一个子应用中已经设置的}if (!app) { //如果当前路由路径pathname没有子应用,直接return返回return}// 2.匹配子应用// 2.1 首先获取当前的路由路径 window.location.pathname// console.log(window.location.pathname)// 3.加载子应用// 加载子应用就是请求获取app的entry资源,资源有很多种,有HTML、css、js,所以我们要一个个来处理// 先来请求html资源,可以使用很多异步请求方式:ajax、aiox、fetch// const html = await fetch(app.entry).then(res => res.text()) //res为请求的所有资源,res.text()为请求到的数据的普通文本即页面的html// console.log(html)// 配置全局环境变量window.__POWERED_BY_QIANKUN__ = truewindow.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + "/"//获取自己封装的加载子应用的api const {template, //teplate为处理之后的html模板字符串getExternalScripts, //调用它会得到所有的script脚本execScripts //用来执行文档中所有的script脚本} = await importHTML(app.entry) //这里必须是异步调用// // 4.渲染子应用,渲染到container中// // 获取渲染的容器const container = document.querySelector(app.container)container.append(template)// // 拿到html中的script脚本// getExternalScripts().then(res => {// })// 执行获取到的脚本,用于获取声明周期const appExports = await execScripts()// console.log(appExports);app.bootstrap = appExports.bootstrapapp.mount = appExports.mountapp.unmount = appExports.unmountconsole.log(app.mount)// 调用子应用的声明周期钩子函数来进行渲染await bootstrap(app)await mount(app)// container.innerHTML = template  //虽然此时在container中已经含有此html页面了,但是依然不能够在页面中渲染出页面//以上不能渲染成功的原因如下:// a.客户端渲染需要通过js来生成内容// b. 浏览器处于安全考虑,innerHTML中的script不会加载执行 ,所以需要我们手动加载执行// 手动加载子应用的script,执行script中的代码// 执行script中的字符串代码可以使用:1.使用eval(str)函数来执行字符串str的js内容;2:使用new Function
}
async function mount(app) {app.mount && (await app.mount({container: document.querySelector(app.container)}))
}async function unmount(app) {app.unmount && (await app.unmount(app.container))
}async function bootstrap(app) {app.bootstrap && (await app.bootstrap(app.container))
}

rewrite-router.js

// 重写路由监听的函数,主要是实现pushState和replaceState在实现路由跳转时,popState事件监听函数不能够监听到其跳转,所以需要重写这两个路由跳转方法// 引入handleRouter函数,用来处理路由跳转后,让其匹配子应用、加载子应用、渲染子应用
import { handleRouter } from "./handle-router";// 用于记录上一个路由
let preRoute = ""
let nextRoute = window.location.pathname
const getPrevRoute = function () {return preRoute
}
const getNextRoute = function () {return nextRoute
}
export {getPrevRoute,getNextRoute
}export const rewriteRouter = function () {// 路由的两种模式:hash、history// 监听hash路由使用window.onhashchange// 这里我们使用history路由来实现,采用history.go、history.back、history.forword方法来进行路由跳转// 在history路由中我们使用onpopstate事件函数来监听history路由的变化,但是popstate事件函数只能监听到history.go、forward、back的切换路由方式,window.addEventListener("popstate", () => {// popState触发的时候,路由已经完成导航了// 且之前的路由preRoute的就等于之前的nextRoute,preRoute = nextRoute// 而跳转的nextRoute路由就是的当前最新的window.loacation.pathnamenextRoute = window.location.pathnamehandleRouter()})// 但是它不能够监听到pushState添加历史记录(就是在页面中点击某个a标签进行跳转的方式,点击页面顺序:a->b->c,记录的历史记录中a、b、c都存在,而replaceState则不同)、replaceState(点击页面顺序:a->b->c,记录的历史记录中只有a->c,即用c代替了b记录,b记录被删除了)切换路由的方式// 对于pushState、replaceState需要通过函数重写的方式进行劫持,也就是说我们重写pushState和replaceState// 但是我们一般都是pushState来跳转链接,是通过this.$router.replace()来触发;而pushState()是通过this.$router.push()来触发// 重写pushState方法const rawPushState = window.history.pushStatewindow.history.pushState = function (...args) {// 导航前preRoute = window.location.pathname //记录跳转前的路由路径rawPushState.apply(window.history, args) //使用pushState进行跳转// 导航后nextRoute = window.location.pathname //记录跳转后的路由路径handleRouter()console.log("终于监视到pushState了");}// 重写replaceState方法const rawReplaceState = window.history.replaceStatewindow.history.replaceState = function (...args) {// 导航前preRoute = window.location.pathname //记录跳转前的路由路径rawReplaceState.apply(window.history, args)// 导航后nextRoute = window.location.pathname //记录跳转后的路由路径handleRouter()console.log("终于监视到replaceState了");}
}

import-html.js:

// 用于解析出html代码中的script脚本的方法
// 其实qiankun框架里面有用一个库即import-html-entry,其封装了一些从html文件中提取script标签,并动态执行script脚本的方法,而且这个库中也封装了沙箱机制import { fetchResource } from "./fetch-resource"// 这里我们仿造import-html-entry库,然后自己手写几个类似的方法
export const importHTML = async function (url) {// 加载子应用就是请求获取app的entry资源,资源有很多种,有HTML、css、js,所以我们要一个个来处理const html = await fetchResource(url)// 先来请求html资源,可以使用很多异步请求方式:ajax、aiox、fetchconst template = document.createElement("div")template.innerHTML = html// 获取template的dom下的所有script脚本const scripts = template.querySelectorAll("script")// 获取所有script标签脚本代码,最后返回一个数组的形式const getExternalScripts = function () {console.log(scripts)// promise.all的返回值是一个promise数组return Promise.all(Array.from(scripts).map(script => {const src = script.getAttribute("src")if (!src) {//如果script脚本没有src,那么就是普通的script标签里面的script代码// 那么就只返回script里面的代码,并封装成promise对象return Promise.resolve(script.innerHTML)} else {//表示此script脚本是外链的资源,资源在src中return fetchResource(//需要判断src是以http开头比如http://www.nativejs.com,则资源是http外网资源;// 如果是一种相对路径资源比如:/src/res则需要手动加上子应用的域名src.startsWith("http") ? src : url + src)//直接发送异步请求}}))}// 获取并执行所有的script脚本代码const execScripts = async function () {// 拿到html中的scripts脚,它是一个script代码字符串构成的数组const scripts = await getExternalScripts()// 手动的构造一个commonJs环境,commonJs规则,里面有一个module对象,还有一个exports对象并且指向module.exports对象const module = { exports: {} }const exports = module.exportsconsole.log(scripts);// 执行scripts数组中的script字符串代码,这里依然是使用eval函数来执行字符串代码scripts.forEach(script => {// eval执行的代码可以访问外部代码eval(script)})// 由于子模块到出的库格式为umd库,并且将返回的数据挂载到了window对象上,// 所以我们可以在window对象上拿到子应用的生命周期钩子函数,需要注意的是生命周期钩子必须写在子应用的入口文件main.js,然后webpack打包的时候首先进入入口文件,然后再递归查找依赖的文件进行打包// 因为我们自己构造了commonJs环境,那么我就能够通过module.exports拿到回调函数factory()返回的结果console.log(module.exports)return module.exports}return {template, //teplate为处理之后的html模板字符串getExternalScripts, //调用它会得到所有的script脚本execScripts //用来执行文档中所有的script脚本}
}

fetch-resource.js

// 异步请求函数
export const fetchResource = function (url) {const html = fetch(url).then(res => res.text()) //res为请求的所有资源,res.text()为请求到的数据的普通文本即页面的htmlreturn html
}

qinakun的样式隔离如何实现?

shadowDom实现

其实是因为start()函数中有一个sandbox沙箱。其中使用shadow dom来解决样式冲突,shadow dom就是一个隔离的环境,他会把子应用的所有内容放在shadowdom里面,shadow dom中的样式不会影响外部的样式。

shadow DOM并不是一个特别新的概念,html中的video标签就是使用shadow DOM的一个案例。使用它时,你在html只会看到一个video标签,但实际上播放器上还有一系列按钮和其他操作,这些就都是封装到shadow dom中的,对外界是不可见的。

shadow使用 subApp.attachShadow({ mode: “open” })创建。之后再在里面添加子元素或者innerHTML。

举个shadow dom样式隔离的案例:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head>
<style>p {color: red;}#shadow-root p {color: green;}
</style><body><!-- 现在我将使用shadow dom来使得子应用和主应用之间的样式不会发生冲突 --><p>wh</p><!-- 子应用内容 --><div id="subApp"><p>子应用wh</p></div><script>let subApp = document.querySelector("#subApp")// shadow dom就相当于一个普通的dom,你可以拿到并操作它,并且不会有样式冲突const shadow = subApp.attachShadow({ mode: "open" })//创建shadow dom,mode指是否可以通过js拿到子应用的dom  shadow.innerHTML = `<p>这是通过shadow dom添加的内容</p>//这里也可以放置style标签,设置shadow dom的样式<style>p {color: green;}</style>`</script>
</body></html>

浏览器页面:

image-20220316113024338

image-20220316113033523

除了shadowdom可以通过添加选择器范围来解决样式冲突。

img

也就是子应用在子应用类下的某个盒子,相当于 div span指代div下的span,和这种方式一样

qiankun的js沙箱如何实现

js沙箱隔离主要分为三种,snapshot sandbox(快照沙箱)、Proxy sandbox(代理沙箱)、lagacySandBox(遗留沙箱)。

snapshot沙箱

快照沙箱的核心思想:即在子应用挂载前对当前主应用的全局变量保存,然后恢复之前的子应用环境,在子应用运行期间正常get和set,在子应用卸载时保存当前变量恢复主应用变量,整个过程类似于中断和中断恢复。

image-20220321213626331

原理就是在子应用激活 / 卸载时分别去经过快照的形式记录/还原状态来实现沙箱的。总结起来,对当前的 window 和记录的快照作 diff 来实现沙箱。

但是快照沙箱明显的缺点就是每次切换时需要去遍历window,这种做法会有较大的时间消耗。

我的总结:也就是在挂载子应用之前,主应用首先深拷贝一份window对象即snapshotWindow,并且将之前子应用的状态modifyWindow赋值给当前window即恢复之前的子应用状态,子应用运行期间的操作都是对这个window正常set和get操作,然后子应用卸载的时候就将当前window保存为modifyWindow以供下次子应用挂载时渲染modifyWindow,同时把之前保存的snapShotWindow重新渲染在主应用window中。

lagacy沙箱
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';function iter(obj: typeof window, callbackFn: (prop: any) => void) {// eslint-disable-next-line guard-for-in, no-restricted-syntaxfor (const prop in obj) {// patch for clearInterval for compatible reason, see #1490if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {callbackFn(prop);}}
}/*** 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器*/
export default class SnapshotSandbox implements SandBox {proxy: WindowProxy;name: string;type: SandBoxType;sandboxRunning = true;private windowSnapshot!: Window;private modifyPropsMap: Record<any, any> = {};constructor(name: string) {this.name = name;this.proxy = window;this.type = SandBoxType.Snapshot;}active() {// 记录当前快照this.windowSnapshot = {} as Window;iter(window, (prop) => {this.windowSnapshot[prop] = window[prop];});// 恢复之前的变更Object.keys(this.modifyPropsMap).forEach((p: any) => {window[p] = this.modifyPropsMap[p];});this.sandboxRunning = true;}inactive() {this.modifyPropsMap = {};iter(window, (prop) => {if (window[prop] !== this.windowSnapshot[prop]) {// 记录变更,恢复环境this.modifyPropsMap[prop] = window[prop];window[prop] = this.windowSnapshot[prop];}});if (process.env.NODE_ENV === 'development') {console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));}this.sandboxRunning = false;}
}
proxy沙箱

子应用主应用间如何通信

qiankun官方提供了actions通信,qiankun内部使用initGlobalState(state)定义全局状态,该方法执行后返回一个MicroAppStateActions实例,实例中包含三个方法,分别是onGlobalStateChange、setGlobalState、offGlobalStateChange。

MicroAppStateActions
onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void //在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback
setGlobalState: (state: Record<string, any>) => boolean, //按一级属性设置全局状态,微应用中只能修改已存在的一级属性(就是用来修改全局状态的
offGlobalStateChange: () => boolean  //移除当前应用的状态监听,微应用 umount 时会默认调用

实战:父子应用之间相互通信:

==第一步:==首先需要在父应用中使用initGlobalState设置全局状态actions并导出供其他组件使用。

src/action.js:

// 此action文件为定义微应用之间全局状态
// 引入qiankun的应用间通信方法initGlobalState
import { initGlobalState, MicroAppStateActions } from 'qiankun'const initialState = {// 这里可以写初始化数据}
const actions = initGlobalState(initialState) //初始化state// 监听actions全局公共状态数据的变化
actions.onGlobalStateChange((state, prevState) => {console.log("主应用变更前:", prevState);console.log("主应用变更后:", state);this.$store.commit("setProject", state) //将获取的最新的公共状态保存到vuex中
})export default actions

==第二步:==然后在main.js中引入actions实例并在注册子应用时通过props传递全局状态actions:

main.js

// 注册的应用列表
const apps = [// 子应用vue应用{name: 'vueApp',  //应用名字// 默认请求的url,并解析里面的js,因为此时父应用请求了子应用里面的资源,所以子应用必须支持跨域entry: "http://localhost:8001",//容器名,子应用挂载到哪个元素container: "#container",//路由匹配激活规则,当路由匹配到activeRule时,就会请求获取entry资源,然后渲染到container容器中activeRule: '/vue',// 通过props实现通信传递值props: { actions, msg: "w" }   //向子应用传递创建的全局状态}
]

第三步:

主应用中的组件要修改全局状态actions,就在此组件中引入actions实例

Home.vue:

<template><div class="home"><button @click="handle1">点击向子应用发送消息</button><button @click="handle2">点击向子应用发送消息</button><p>当前显示的项目:{{ project }}</p></div>
</template><script>
import HelloWorld from "@/components/HelloWorld.vue"; //引入的HelloWorld组件
import actions from "../action";export default {name: "Home",data() {return {mes1: {project_id: "项目1",},mes2: {project_id: "项目2",},};},computed: {project() {return this.$store.state.project_id;},},mounted() {// 需要在mounted钩子函数中注册qiankun的观察者函数// 注册一个观察者函数// 一旦修改actions的内容就会触发这个onGlobalStateChange监听函数actions.onGlobalStateChange((state, prevState) => {// state为变更后的状态,prevState为变更前的状态console.log("主应用观察者,改变前的state为:", prevState);console.log("主应用观察者,改变后的state为:", state);});},methods: {handle1() {actions.setGlobalState(this.mes1); //修改全局的actionsthis.$router.push("/vue"); //跳转到vue子应用中},handle2() {actions.setGlobalState(this.mes2); //修改全局的actionsthis.$router.push("/vue"); //跳转到vue子应用中},},components: {HelloWorld, //注册组件},
};
</script>

第四步:配置子应用的全局状态Actions,子应用中的全局状态必须要跟主应用中的全局状态变量属性名相同,比如主应用中全局状态变量为{project_id: “项目2”},则子应用中也需要保证在setGloabalState时也需要设定相同的变量名。

先在子应用中配置一个空的actions实例为以后重新赋值从主应用中传递过来的actions:

actions.js

function emptyAction() {// 警告:提示当前使用的是空 Actionconsole.warn("Current execute action is empty!");
}// 我们首先设置一个用于通信的Actions类class Actions {actions = {onGlobalStateChange: emptyAction,setGlobalState: emptyAction}constructor() {}// 默认值为空Action// 设置actionssetActions(actions) {this.actions = actions}// 映射onGlobalStateChange(...args) {return this.actions.onGlobalStateChange(...args)}// 映射setGlobalState(...args) {return this.actions.setGlobalState(...args)}
}const actions = new Actions()
export default actions

然后在mounted的生命周期里注入actions实例:

main.js

function render(props) {if (props) {actions.setActions(props)}const { container } = props// 渲染的时候赋值instance = new Vue({router,store,render: h => h(App)}).$mount(container ? container.querySelector("#app") : '#app') //这里是挂载到自己的html中,基座会拿到这个挂载后的html,将其插入进去
}

子应用向主应用发送数据(子应用中修改数据,可以在主应用中监听到)

子应用的组件Home.vue:

<template><div class="home"><button @click="handle">快点我向父应用发送数据</button><p>{{ msg }}</p><img alt="Vue logo" src="../assets/logo.png" /><HelloWorld msg="Welcome to Your Vue.js App" /></div>
</template><script>
// @ is an alias to /src
import HelloWorld from "@/components/HelloWorld.vue";
import actions from "../actions";
export default {name: "Home",data() {return {msg: "2",};},mounted() {// console.log("cccc");// console.log(actions);actions.onGlobalStateChange((state) => {console.log("我是子应用,我检测到数据了:", state);this.msg = state;}, true); //onGlobalStateChange的第二个参数设置为true,则会立即触发一次观察者函数},methods: {handle() {actions.setGlobalState({ project_id: "项目520" });},},components: {HelloWorld,},
};
</script>

这篇关于微前端qiankun框架的底层实现原理的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/342352

相关文章

C++使用栈实现括号匹配的代码详解

《C++使用栈实现括号匹配的代码详解》在编程中,括号匹配是一个常见问题,尤其是在处理数学表达式、编译器解析等任务时,栈是一种非常适合处理此类问题的数据结构,能够精确地管理括号的匹配问题,本文将通过C+... 目录引言问题描述代码讲解代码解析栈的状态表示测试总结引言在编程中,括号匹配是一个常见问题,尤其是在

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

部署Vue项目到服务器后404错误的原因及解决方案

《部署Vue项目到服务器后404错误的原因及解决方案》文章介绍了Vue项目部署步骤以及404错误的解决方案,部署步骤包括构建项目、上传文件、配置Web服务器、重启Nginx和访问域名,404错误通常是... 目录一、vue项目部署步骤二、404错误原因及解决方案错误场景原因分析解决方案一、Vue项目部署步骤

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

python使用fastapi实现多语言国际化的操作指南

《python使用fastapi实现多语言国际化的操作指南》本文介绍了使用Python和FastAPI实现多语言国际化的操作指南,包括多语言架构技术栈、翻译管理、前端本地化、语言切换机制以及常见陷阱和... 目录多语言国际化实现指南项目多语言架构技术栈目录结构翻译工作流1. 翻译数据存储2. 翻译生成脚本

如何通过Python实现一个消息队列

《如何通过Python实现一个消息队列》这篇文章主要为大家详细介绍了如何通过Python实现一个简单的消息队列,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录如何通过 python 实现消息队列如何把 http 请求放在队列中执行1. 使用 queue.Queue 和 reque

Python如何实现PDF隐私信息检测

《Python如何实现PDF隐私信息检测》随着越来越多的个人信息以电子形式存储和传输,确保这些信息的安全至关重要,本文将介绍如何使用Python检测PDF文件中的隐私信息,需要的可以参考下... 目录项目背景技术栈代码解析功能说明运行结php果在当今,数据隐私保护变得尤为重要。随着越来越多的个人信息以电子形

使用 sql-research-assistant进行 SQL 数据库研究的实战指南(代码实现演示)

《使用sql-research-assistant进行SQL数据库研究的实战指南(代码实现演示)》本文介绍了sql-research-assistant工具,该工具基于LangChain框架,集... 目录技术背景介绍核心原理解析代码实现演示安装和配置项目集成LangSmith 配置(可选)启动服务应用场景