Vite入门从手写一个乞丐版的Vite开始(下)
上一篇Vite入門從手寫一個乞丐版的Vite開始(上)我們已經成功的將頁面渲染出來了,這一篇我們來簡單的實現一下熱更新的功能。
所謂熱更新就是修改了文件,不用刷新頁面,頁面的某個部分就自動更新了,聽著似乎挺簡單的,但是要實現一個很完善的熱更新還是很復雜的,要考慮的情況很多,所以本文只會實現一個最基礎的熱更新效果。
創建WebSocket連接
瀏覽器顯然是不知道文件有沒有修改的,所以需要后端進行推送,我們先來建立一個WebSocket連接。
// app.js const server = http.createServer(app); const WebSocket = require("ws");// 創建WebSocket服務 const createWebSocket = () => {// 創建一個服務實例const wss = new WebSocket.Server({ noServer: true });// 不用額外創建http服務,直接使用我們自己創建的http服務// 接收到http的協議升級請求server.on("upgrade", (req, socket, head) => {// 當子協議為vite-hmr時就處理http的升級請求if (req.headers["sec-websocket-protocol"] === "vite-hmr") {wss.handleUpgrade(req, socket, head, (ws) => {wss.emit("connection", ws, req);});}});// 連接成功wss.on("connection", (socket) => {socket.send(JSON.stringify({ type: "connected" }));});// 發送消息方法const sendMsg = (payload) => {const stringified = JSON.stringify(payload, null, 2);wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(stringified);}});};return {wss,sendMsg,}; }; const { wss, sendMsg } = createWebSocket();server.listen(3000);WebSocket和我們的服務共用一個http請求,當接收到http協議的升級請求后,判斷子協議是否是vite-hmr,是的話我們就把創建的WebSocket實例連接上去,這個子協議是自己定義的,通過設置子協議,單個服務器可以實現多個WebSocket 連接,就可以根據不同的協議處理不同類型的事情,服務端的WebSocket創建完成以后,客戶端也需要創建,但是客戶端是不會有這些代碼的,所以需要我們手動注入,創建一個文件client.js:
// client.js// vite-hmr代表自定義的協議字符串 const socket = new WebSocket("ws://localhost:3000/", "vite-hmr");socket.addEventListener("message", async ({ data }) => {const payload = JSON.parse(data); });接下來我們把這個client.js注入到html文件,修改之前html文件攔截的邏輯:
// app.js const clientPublicPath = "/client.js";app.use(async function (req, res, next) {// 提供html頁面if (req.url === "/index.html") {let html = readFile("index.html");const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`;html = html.replace(/<head>/, `$&${devInjectionCode}`);send(res, html, "html");} })通過import的方式引入,所以我們需要攔截一下這個請求:
// app.js app.use(async function (req, res, next) {if (req.url === clientPublicPath) {// 提供client.jslet js = fs.readFileSync(path.join(__dirname, "./client.js"), "utf-8");send(res, js, "js");} })可以看到已經連接成功。
監聽文件改變
接下來我們要初始化一下對文件修改的監聽,監聽文件的改變使用chokidar:
// app.js const chokidar = require(chokidar);// 創建文件監聽服務 const createFileWatcher = () => {const watcher = chokidar.watch(basePath, {ignored: [/node_modules/, /\.git/],awaitWriteFinish: {stabilityThreshold: 100,pollInterval: 10,},});return watcher; }; const watcher = createFileWatcher();watcher.on("change", (file) => {// file文件修改了 })構建導入依賴圖
為什么要構建依賴圖呢,很簡單,比如一個模塊改變了,僅僅更新它自己肯定還不夠,依賴它的模塊都需要修改才對,要做到這一點自然要能知道哪些模塊依賴它才行。
// app.js const importerMap = new Map(); const importeeMap = new Map();// map : key -> set // map : 模塊 -> 依賴該模塊的模塊集合 const ensureMapEntry = (map, key) => {let entry = map.get(key);if (!entry) {entry = new Set();map.set(key, entry);}return entry; };需要用到的變量和函數就是上面幾個,importerMap用來存放模塊到依賴它的模塊之間的映射;importeeMap用來存放模塊到該模塊所依賴的模塊的映射,主要作用是用來刪除不再依賴的模塊,比如a一開始依賴b和c,此時importerMap里面存在b -> a和c -> a的映射關系,然后我修改了一下a,刪除了對c的依賴,那么就需要從importerMap里面也同時刪除c -> a的映射關系,這時就可以通過importeeMap來獲取到之前的a -> [b, c]的依賴關系,跟此次的依賴關系a -> [b]進行比對,就可以找出不再依賴的c模塊,然后在importerMap里刪除c -> a的依賴關系。
接下來我們從index.html頁面開始構建依賴圖,index.html內容如下:
可以看到它依賴了main.js,修改攔截html的方法:
// app.js app.use(async function (req, res, next) {// 提供html頁面if (req.url === "/index.html") {let html = readFile("index.html");// 查找模塊依賴圖const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm;const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/;// 找出script標簽html = html.replace(scriptRE, (matched, openTag) => {const srcAttr = openTag.match(srcRE);if (srcAttr) {// 創建script到html的依賴關系const importee = removeQuery(srcAttr[1] || srcAttr[2]);ensureMapEntry(importerMap, importee).add(removeQuery(req.url));}return matched;});// 注入client.js// ...} })接下來我們需要分別修改js的攔截方法,注冊依賴關系;修改Vue單文件的攔截方法,注冊js部分的依賴關系,因為上一篇文章里我們已經把轉換裸導入的邏輯都提取成一個公共函數parseBareImport了,所以我們只要修改這個函數就可以了:
// 處理裸導入 // 增加了importer入參,req.url const parseBareImport = async (js, importer) => {await init;let parseResult = parseEsModule(js);let s = new MagicString(js);importer = removeQuery(importer);// ++parseResult[0].forEach((item) => {let url = "";if (item.n[0] !== "." && item.n[0] !== "/") {url = `/@module/${item.n}?import`;} else {url = `${item.n}?import`;}s.overwrite(item.s, item.e, url);// 注冊importer模塊所以依賴的模塊到它的映射關系ensureMapEntry(importerMap, removeQuery(url)).add(importer);// ++});return s.toString(); };再來增加一下前面提到的去除不再依賴的關系的邏輯:
// 處理裸導入 const parseBareImport = async (js, importer) => {// ...importer = removeQuery(importer);// 上一次的依賴集合const prevImportees = importeeMap.get(importer);// ++// 這一次的依賴集合const currentImportees = new Set();// ++importeeMap.set(importer, currentImportees);// ++parseResult[0].forEach((item) => {// ...let importee = removeQuery(url);// ++// url -> 依賴currentImportees.add(importee);// ++// 依賴 -> urlensureMapEntry(importerMap, importee).add(importer);});// 刪除不再依賴的關系++if (prevImportees) {prevImportees.forEach((importee) => {if (!currentImportees.has(importee)) {// importer不再依賴importee,所以要從importee的依賴集合中刪除importerconst importers = importerMap.get(importee);if (importers) {importers.delete(importer);}}});}return s.toString(); };Vue單文件的熱更新
先來實現一下Vue單文件的熱更新,先監聽一下Vue單文件的改變事件:
// app.js // 監聽文件改變 watcher.on("change", (file) => {if (file.endsWith(".vue")) {handleVueReload(file);} });如果修改的文件是以.vue結尾,那么就進行處理,怎么處理呢,Vue單文件會解析成js、template、style三部分,我們把解析數據緩存起來,當文件修改了以后會再次進行解析,然后分別和上一次的解析結果進行比較,判斷單文件的哪部分發生變化了,最后給瀏覽器發送不同的事件,由前端頁面來進行不同的處理,緩存我們使用lru-cache:
// app.js const LRUCache = require("lru-cache");// 緩存Vue單文件的解析結果 const vueCache = new LRUCache({max: 65535, });然后修改一下Vue單文件的攔截方法,增加緩存:
// app.js app.use(async function (req, res, next) {if (/\.vue\??[^.]*$/.test(req.url)) {// ...// vue單文件let descriptor = null;// 如果存在緩存則直接使用緩存let cached = vueCache.get(removeQuery(req.url));if (cached) {descriptor = cached;} else {// 否則進行解析,并且將解析結果進行緩存descriptor = parseVue(vue).descriptor;vueCache.set(removeQuery(req.url), descriptor);}// ...} })然后就來到handleVueReload方法了:
// 處理Vue單文件的熱更新 const handleVueReload = (file) => {file = filePathToUrl(file); };// 處理文件路徑到url const filePathToUrl = (file) => {return file.replace(/\\/g, "/").replace(/^\.\.\/test/g, ""); };我們先轉換了一下文件路徑,因為監聽到的是本地路徑,和請求的url是不一樣的:
const handleVueReload = (file) => {file = filePathToUrl(file);// 獲取上一次的解析結果const prevDescriptor = vueCache.get(file);// 從緩存中刪除上一次的解析結果vueCache.del(file);if (!prevDescriptor) {return;}// 解析let vue = readFile(file);descriptor = parseVue(vue).descriptor;vueCache.set(file, descriptor); };接著獲取了一下緩存數據,然后進行了這一次的解析,并更新緩存,接下來就要判斷哪一部分發生了改變。
熱更新template
我們先來看一下比較簡單的模板熱更新:
const handleVueReload = (file) => {// ...// 檢查哪部分發生了改變const sendRerender = () => {sendMsg({type: "vue-rerender",path: file,});};// template改變了發送rerender事件if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {return sendRerender();} }// 判斷Vue單文件解析后的兩個部分是否相同 function isEqualBlock(a, b) {if (!a && !b) return true;if (!a || !b) return false;if (a.src && b.src && a.src === b.src) return true;if (a.content !== b.content) return false;const keysA = Object.keys(a.attrs);const keysB = Object.keys(b.attrs);if (keysA.length !== keysB.length) {return false;}return keysA.every((key) => a.attrs[key] === b.attrs[key]); }邏輯很簡單,當template部分發生改變后向瀏覽器發送一個rerender事件,帶上修改模塊的url。
現在我們來修改一下HelloWorld.vue的template看看:
可以看到已經成功收到了消息。
接下來需要修改一下client.js文件,增加收到vue-rerender消息后的處理邏輯。
文件更新了,瀏覽器肯定需要請求一下更新的文件,Vite使用的是import()方法,但是這個方法js本身是沒有的,另外筆者沒有找到是哪里注入的,所以加載模塊的邏輯只能自己來簡單實現一下:
// client.js // 回調id let callbackId = 0; // 記錄回調 const callbackMap = new Map(); // 模塊導入后調用的全局方法 window.onModuleCallback = (id, module) => {document.body.removeChild(document.getElementById("moduleLoad"));// 執行回調let callback = callbackMap.get(id);if (callback) {callback(module);} };// 加載模塊 const loadModule = ({ url, callback }) => {// 保存回調let id = callbackId++;callbackMap.set(id, callback);// 創建一個模塊類型的scriptlet script = document.createElement("script");script.type = "module";script.id = "moduleLoad";script.innerHTML = `import * as module from '${url}'window.onModuleCallback(${id}, module)`;document.body.appendChild(script); };因為要加載的都是ES模塊,直接請求是不行的,所以創建一個type為module的script標簽,來讓瀏覽器加載,這樣請求都不用自己發,只要把想辦法獲取到模塊的導出就行了,這個也很簡單,創建一個全局函數即可,這個很像jsonp的原理。
接下來就可以處理vue-rerender消息了:
// app.js socket.addEventListener("message", async ({ data }) => {const payload = JSON.parse(data);handleMessage(payload); });const handleMessage = (payload) => {switch (payload.type) {case "vue-rerender":loadModule({url: payload.path + "?type=template&t=" + Date.now(),callback: (module) => {window.__VUE_HMR_RUNTIME__.rerender(payload.path, module.render);},});break;} };就這么簡單,我們來修改一下HelloWorld.vue文件的模板來看看:
可以看到沒有刷新頁面,但是更新了,接下來詳細解釋一下原理。
因為我們修改的是模板部分,所以請求的url為payload.path + "?type=template,這個源于上一篇文章里我們請求Vue單文件的模板部分是這么設計的,為什么要加個時間戳呢,因為不加的話瀏覽器認為這個模塊已經加載過了,是不會重新請求的。
模板部分的請求結果如下:
導出了一個render函數,這個其實就是HelloWorld.vue組件的渲染函數,所以我們通過module.render來獲取這個函數。
__VUE_HMR_RUNTIME__.rerender這個函數是哪里來的呢,其實來自于Vue,Vue非生產環境的源碼會提供一個__VUE_HMR_RUNTIME__對象,顧名思義就是用于熱更新的,有三個方法:
rerender就是其中一個:
function rerender(id, newRender) {const record = map.get(id);if (!record)return;Array.from(record).forEach(instance => {if (newRender) {instance.render = newRender;// 1}instance.renderCache = [];isHmrUpdating = true;instance.update();// 2isHmrUpdating = false;}); }核心代碼就是上面的1、2兩行,直接用新的渲染函數覆蓋組件舊的渲染函數,然后觸發組件更新就達到了熱更新的效果。
另外要解釋一下其中涉及到的id,需要熱更新的組件會被添加到map里,那怎么判斷一個組件是不是需要熱更新呢,也很簡單,給它添加一個屬性即可:
在mountComponent方法里會判斷組件是否存在__hmrId屬性,存在則認為是需要進行熱更新的,那么就添加到map里,注冊方法如下:
這個__hmrId屬性需要我們手動添加,所以需要修改一下之前攔截Vue單文件的方法:
// app.js app.use(async function (req, res, next) {if (/\.vue\??[^.]*$/.test(req.url)) {// vue單文件// ...// 添加熱更新標志code += `\n__script.__hmrId = ${JSON.stringify(removeQuery(req.url))}`;// ++// 導出code += `\nexport default __script`;// ...} })熱更新js
趁熱打鐵,接下來看一下Vue單文件中的js部分發生了修改怎么進行熱更新。
基本套路是一樣的,檢查兩次的js部分是否發生了修改了,修改了則向瀏覽器發送熱更新消息:
// app.js const handleVueReload = (file) => {const sendReload = () => {sendMsg({type: "vue-reload",path: file,});};// js部分發生了改變發送reload事件if (!isEqualBlock(descriptor.script, prevDescriptor.script)) {return sendReload();} }js部分發生改變了就發送一個vue-reload消息,接下來修改client.js增加對這個消息的處理邏輯:
// client.js const handleMessage = (payload) => {switch (payload.type) {case "vue-reload":loadModule({url: payload.path + "?t=" + Date.now(),callback: (module) => {window.__VUE_HMR_RUNTIME__.reload(payload.path, module.default);},});break;} }和模板熱更新很類似,只不過是調用reload方法,這個方法會稍微復雜一點:
function reload(id, newComp) {const record = map.get(id);if (!record)return;Array.from(record).forEach(instance => {const comp = instance.type;if (!hmrDirtyComponents.has(comp)) {// 更新原組件extend(comp, newComp);for (const key in comp) {if (!(key in newComp)) {delete comp[key];}}// 標記為臟組件,在虛擬DOM樹patch的時候會直接替換hmrDirtyComponents.add(comp);// 重新加載后取消標記組件queuePostFlushCb(() => {hmrDirtyComponents.delete(comp);});}if (instance.parent) {// 強制父實例重新渲染queueJob(instance.parent.update);}else if (instance.appContext.reload) {// 通過createApp()裝載的根實例具有reload方法instance.appContext.reload();}else if (typeof window !== 'undefined') {window.location.reload();}}); }通過注釋應該能大概看出來它的原理,通過強制父實例重新渲染、調用根實例的reload方法、通過標記為臟組件等等方式來重新渲染組件達到更新的效果。
style熱更新
樣式更新的情況比較多,除了修改樣式本身,還有作用域修改了、使用到了CSS變量等情況,簡單起見,我們只考慮修改了樣式本身。
根據上一篇的介紹,Vue單文件中的樣式也是通過js類型發送到瀏覽器,然后動態創建style標簽插入到頁面,所以我們需要能刪除之前添加的標簽,這就需要給添加的style標簽增加一個id了,修改一下上一篇文章里我們編寫的insertStyle方法:
// app.js // css to js const cssToJs = (css, id) => {return `const insertStyle = (css) => {// 刪除之前的標簽++if ('${id}') {let oldEl = document.getElementById('${id}')if (oldEl) document.head.removeChild(oldEl)}let el = document.createElement('style')el.setAttribute('type', 'text/css')el.id = '${id}' // ++el.innerHTML = cssdocument.head.appendChild(el)}insertStyle(\`${css}\`)export default insertStyle`; };給style標簽增加一個id,然后添加之前先刪除之前的標簽,接下來需要分別修改一下css的攔截邏輯增加removeQuery(req.url)作為id;以及Vue單文件的style部分的攔截請求,增加removeQuery(req.url) + '-' + index作為id,要加上index是因為一個Vue單文件里可能有多個style標簽。
接下來繼續修改handleVueReload方法:
// app.js const handleVueReload = (file) => {// ...// style部分發生了改變const prevStyles = prevDescriptor.styles || []const nextStyles = descriptor.styles || []nextStyles.forEach((_, i) => {if (!prevStyles[i] || !isEqualBlock(prevStyles[i], nextStyles[i])) {sendMsg({type: 'style-update',path: `${file}?import&type=style&index=${i}`,})}}) }遍歷新的樣式數據,根據之前的進行對比,如果某個樣式塊之前沒有或者不一樣那就發送style-update事件,注意url需要帶上import及type=style參數,這是上一篇里我們規定的。
client.js也要配套修改一下:
// client.js const handleMessage = (payload) => {switch (payload.type) {case "style-update":loadModule({url: payload.path + "&t=" + Date.now(),});break; } }很簡單,加上時間戳重新加載一下樣式文件即可。
不過還有個小問題,比如原來有兩個style塊,我們刪掉了一個,目前頁面上還是存在的,比如一開始存在兩個style塊:
刪掉第二個style塊,也就是設置背景顏色的那個:
可以看到還是存在,我們是通過索引來添加的,所以更新后有多少個樣式塊,就會從頭覆蓋之前已經存在的多少個樣式塊,最后多出來的是不會被刪除的,所以需要手動刪除不再需要的標簽:
// app.js const handleVueReload = (file) => {// ...// 刪除已經被刪掉的樣式塊prevStyles.slice(nextStyles.length).forEach((_, i) => {sendMsg({type: 'style-remove',path: file,id: `${file}-${i + nextStyles.length}`})}) }發送一個style-remove事件,通知頁面刪除不再需要的標簽:
// client.js const handleMessage = (payload) => {switch (payload.type) {case "style-remove":document.head.removeChild(document.getElementById(payload.id));break;} }可以看到被成功刪掉了。
普通js文件的熱更新
最后我們來看一下非Vue單文件,普通js文件更新后要怎么處理。
增加一個處理js熱更新的函數:
// app.js // 監聽文件改變 watcher.on("change", (file) => {if (file.endsWith(".vue")) {handleVueReload(file);} else if (file.endsWith(".js")) {// ++handleJsReload(file);// ++} });普通js熱更新就需要用到前面的依賴圖數據了,如果監聽到某個js文件修改了,先判斷它是否在依賴圖中,不是的話就不用管,是的話就遞歸獲取所有依賴它的模塊,因為所有模塊的最上層依賴肯定是index.html,如果只是簡單的獲取所有依賴模塊再更新,那么每次都相當于要刷新整個頁面了,所以我們規定如果檢查到某個依賴是Vue單文件,那么就代表支持熱更新,否則就相當于走到死胡同,需要刷新整個頁面。
// 處理js文件的熱更新 const handleJsReload = (file) => {file = filePathToUrl(file);// 因為構建依賴圖的時候有些是以相對路徑引用的,而監聽獲取到的都是絕對路徑,所以稍微兼容一下let importers = getImporters(file);// 遍歷直接依賴if (importers && importers.size > 0) {// 需要進行熱更新的模塊const hmrBoundaries = new Set();// 遞歸依賴圖獲取要更新的模塊const hasDeadEnd = walkImportChain(importers, hmrBoundaries);const boundaries = [...hmrBoundaries];// 無法熱更新,刷新整個頁面if (hasDeadEnd) {sendMsg({type: "full-reload",});} else {// 可以熱更新sendMsg({type: "multi",// 可能有多個模塊,所以發送一個multi類型的消息updates: boundaries.map((boundary) => {return {type: "vue-reload",path: boundary,};}),});}} };// 獲取模塊的直接依賴模塊 const getImporters = (file) => {let importers = importerMap.get(file);if (!importers || importers.size <= 0) {importers = importerMap.get("." + file);}return importers; };遞歸獲取修改的js文件的依賴模塊,判斷是否支持熱更新,支持則發送熱更新事件,否則發送刷新整個頁面事件,因為可能同時要更新多個模塊,所以通過type=multi來標識。
看一下遞歸的方法walkImportChain:
// 遞歸遍歷依賴圖 const walkImportChain = (importers, hmrBoundaries, currentChain = []) => {for (const importer of importers) {if (importer.endsWith(".vue")) {// 依賴是Vue單文件那么支持熱更新,添加到熱更新模塊集合里hmrBoundaries.add(importer);} else {// 獲取依賴模塊的再上層用來模塊let parentImpoters = getImporters(importer);if (!parentImpoters || parentImpoters.size <= 0) {// 如果沒有上層依賴了,那么代表走到死胡同了return true;} else if (!currentChain.includes(importer)) {// 通過currentChain來存儲已經遍歷過的模塊// 遞歸再上層的依賴if (walkImportChain(parentImpoters,hmrBoundaries,currentChain.concat(importer))) {return true;}}}}return false; };邏輯很簡單,就是遞歸遇到Vue單文件就停止,否則繼續遍歷,直到頂端,代表走到死胡同。
最后再來修改一下client.js:
// client.js socket.addEventListener("message", async ({ data }) => {const payload = JSON.parse(data);// 同時需要更新多個模塊if (payload.type === "multi") {// ++payload.updates.forEach(handleMessage);// ++} else {handleMessage(payload);} });如果消息類型是multi,那么就遍歷updates列表依次調用處理方法:
// client.js const handleMessage = (payload) => {switch (payload.type) {case "full-reload":location.reload();break;} }vue-rerender事件之前已經有了,所以只需要增加一個刷新整個頁面的方法即可。
測試一下,App.vue里面引入一個test.js文件:
// App.vue <script> import test from "./test.js";export default {data() {return {text: "",};},mounted() {this.text = test();}, }; </script><template><div><p>{{ text }}</p></div> </template>test.js又引入了test2.js:
// test.js import test2 from "./test2.js";export default function () {let a = test2();let b = "我是測試1";return a + " --- " + b; }// test2.js export default function () {return '我是測試2' }接下來修改test2.js測試效果:
可以看到重新發送了請求,但是頁面并沒有更新,這是為什么呢,其實還是緩存問題:
App.vue導入的兩個文件之前已經請求過了,所以瀏覽器會直接使用之前請求的結果,并不會重新發送請求,這要怎么解決呢,很簡單,可以看到請求的App.vue的url是帶了時間戳的,所以我們可以檢查請求模塊的url是否存在時間戳,存在則把它依賴的所有模塊路徑也都帶上時間戳,這樣就會觸發重新請求了,修改一下模塊路徑轉換方法parseBareImport:
// app.js // 處理裸導入 const parseBareImport = async (js, importer) => {// ...// 檢查模塊url是否存在時間戳let hast = checkQueryExist(importer, "t");// ++// ...parseResult[0].forEach((item) => {let url = "";if (item.n[0] !== "." && item.n[0] !== "/") {url = `/@module/${item.n}?import${hast ? "&t=" + Date.now() : ""}`;// ++} else {url = `${item.n}?import${hast ? "&t=" + Date.now() : ""}`;// ++}// ...})// ... }再來測試一下:
可以看到成功更新了。最后我們再來測試運行刷新整個頁面的情況,修改一下main.js文件即可:
總結
本文參考Vite-1.0.0-rc.5版本寫了一個非常簡單的Vite,簡化了非常多的細節,旨在對Vite及熱更新有一個基礎的認識,其中肯定有不合理或錯誤之處,歡迎指出~
示例代碼在:https://github.com/wanglin2/vite-demo。
總結
以上是生活随笔為你收集整理的Vite入门从手写一个乞丐版的Vite开始(下)的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: Linux下3种常用的网络测速工具简介
- 下一篇: 机器人零力拖动技术路线
